diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2fef73a6..a309a9fb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 90 # xtask ci runs cargo test: the demo scene cache is per process, so nextest is slower here. tools: "" diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 19470cf69..9077bac2c 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -28,4 +28,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: require-craft-fonts: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7356f66fe..d1841ebe7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: with: app-name: lightcraft app-title: LightCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e78fe1ea3..d13c3cb4a 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -16,6 +16,12 @@ concurrency: jobs: sonarcloud: - # Tracked at @main so org-wide CI improvements propagate without PR churn. + # Track shared CI improvements on main. uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + with: + shards: 8 + python-coverage-source: packaging/linux/flatpak + python-coverage-script: packaging/linux/flatpak/test_generate_cargo_sources.py + timeout-minutes: 120 + fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index 78de058c3..4b52046c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,21 +20,21 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -43,43 +43,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -87,9 +81,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -105,13 +99,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -119,11 +113,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -164,7 +159,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -218,13 +213,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -288,14 +298,15 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", + "wl-clipboard-rs", "x11rb", ] @@ -584,21 +595,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -609,10 +617,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -620,6 +631,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -671,7 +692,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -688,14 +709,38 @@ dependencies = [ ] [[package]] -name = "brotli" -version = "8.0.4" +name = "borsh" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.0.1", ] [[package]] @@ -704,8 +749,18 @@ version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -833,7 +888,7 @@ dependencies = [ "safetensors 0.7.0", "thiserror 2.0.21", "yoke 0.8.3", - "zip", + "zip 7.2.0", ] [[package]] @@ -843,7 +898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fdfe9d06de16ce49961e49084e5b79a75a9bdf157246e7c7b6328e87a7aa25d" dependencies = [ "half", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "once_cell", @@ -920,7 +975,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -930,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -965,6 +1031,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1042,6 +1117,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1055,7 +1140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -1068,10 +1153,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1081,25 +1172,110 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "serde", + "skrifa", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.0", +] + +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", - "serde", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", ] [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -1188,7 +1364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1219,6 +1395,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dhat" version = "0.3.3" @@ -1275,7 +1457,18 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -1308,6 +1501,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-stack" version = "0.10.0" @@ -1350,9 +1549,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1384,9 +1582,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1401,7 +1598,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1409,6 +1606,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1420,9 +1618,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1440,9 +1637,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1460,30 +1656,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1495,9 +1694,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1543,9 +1741,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1591,9 +1788,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1601,7 +1797,7 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", @@ -1609,16 +1805,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1790,12 +1987,39 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1825,6 +2049,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2336,8 +2566,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2379,12 +2612,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2419,7 +2652,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2528,10 +2761,32 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "read-fonts 0.41.0", "smallvec", ] +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2540,7 +2795,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -2551,7 +2806,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2599,6 +2854,199 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke 0.8.3", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", + "writeable", + "yoke 0.8.3", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "image" version = "0.25.10" @@ -2885,7 +3333,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" dependencies = [ - "brotli-decompressor", + "brotli-decompressor 5.0.3", "jxl-bitstream", "jxl-frame", "jxl-grid", @@ -2917,7 +3365,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" dependencies = [ - "brotli-decompressor", + "brotli-decompressor 5.0.3", "jxl-bitstream", "jxl-color", "jxl-frame", @@ -3012,12 +3460,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3043,9 +3491,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libfuzzer-sys" @@ -3087,9 +3535,10 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", + "craft-update", "eframe", "egui", "lightcraft-codecs", @@ -3100,7 +3549,7 @@ dependencies = [ "lightcraft-ui-egui", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pollster 1.0.1", @@ -3113,7 +3562,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "crc32fast", @@ -3131,7 +3580,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3147,7 +3596,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.13.0" +version = "0.14.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3178,7 +3627,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3187,12 +3636,12 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.13.0" +version = "0.14.0" dependencies = [ "faer-core", "lightcraft-denoise-core", "lightcraft-raster", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "rayon", "serde", @@ -3204,10 +3653,11 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ + "dhat", "lightcraft-raster", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "rayon", "serde", @@ -3219,11 +3669,11 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-color", "lightcraft-geom", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "serde", "serde_json", @@ -3231,12 +3681,12 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "ab_glyph", "chrono", "craft-docpath", - "harfrust", + "harfrust 0.13.3", "jxl-oxide", "libc", "lightcraft-catalog", @@ -3264,7 +3714,7 @@ dependencies = [ "lightcraft-testkit", "lightcraft-tiff", "log", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "rayon", "serde", "serde_json", @@ -3275,7 +3725,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3290,7 +3740,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.13.0" +version = "0.14.0" dependencies = [ "log", "rustls", @@ -3301,15 +3751,15 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.13.0" +version = "0.14.0" dependencies = [ - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "serde", ] [[package]] name = "lightcraft-geom" -version = "0.13.0" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -3317,7 +3767,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.13.0" +version = "0.14.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3337,7 +3787,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.13.0" +version = "0.14.0" dependencies = [ "heic-rs", "rayon", @@ -3345,23 +3795,23 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.13.0" +version = "0.14.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", - "quick-xml", + "quick-xml 0.41.0", ] [[package]] name = "lightcraft-mcp" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3372,7 +3822,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3388,20 +3838,20 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "lightcraft-geom", "lightcraft-tiff", "proptest", - "quick-xml", + "quick-xml 0.41.0", "serde", "serde_json", ] [[package]] name = "lightcraft-pipeline" -version = "0.13.0" +version = "0.14.0" dependencies = [ "dhat", "image", @@ -3420,7 +3870,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.13.0" +version = "0.14.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3432,7 +3882,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.13.0" +version = "0.14.0" dependencies = [ "libc", "lightcraft-color", @@ -3444,7 +3894,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.13.0" +version = "0.14.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3455,7 +3905,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-testkit", "lightcraft-tiff", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "rayon", "serde", @@ -3468,7 +3918,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.13.0" +version = "0.14.0" dependencies = [ "image", "lightcraft-color", @@ -3479,7 +3929,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.13.0" +version = "0.14.0" dependencies = [ "candle-core", "candle-nn", @@ -3493,7 +3943,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-raster", "serde", @@ -3502,15 +3952,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.13.0" +version = "0.14.0" [[package]] name = "lightcraft-testkit" -version = "0.13.0" +version = "0.14.0" [[package]] name = "lightcraft-tiff" -version = "0.13.0" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -3519,9 +3969,11 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-ui", + "craft-update", + "craft-window", "egui", "egui_kittest", "lightcraft-catalog", @@ -3548,7 +4000,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "crc32fast", "eframe", @@ -3585,6 +4037,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] + [[package]] name = "litrs" version = "1.0.0" @@ -3738,18 +4199,17 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png", "thiserror 2.0.21", "windows-sys 0.61.2", @@ -3844,6 +4304,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -3924,6 +4396,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -4034,9 +4512,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -4065,7 +4543,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4115,7 +4593,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4126,7 +4604,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -4183,7 +4661,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4194,7 +4672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4231,7 +4709,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4256,7 +4734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4301,7 +4779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4361,6 +4839,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -4390,6 +4874,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -4493,10 +4987,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -4505,9 +5010,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -4515,9 +5020,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -4528,9 +5033,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -4616,6 +5121,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -4661,7 +5179,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4682,7 +5200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4702,6 +5220,33 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4765,16 +5310,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20 0.10.2", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4858,6 +5404,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -4899,6 +5454,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4937,6 +5502,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -4949,11 +5520,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5042,7 +5613,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5085,6 +5656,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -5151,7 +5733,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5263,17 +5845,31 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.15", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -5283,6 +5879,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.15", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-rustcrypto" version = "0.0.2-alpha" @@ -5383,6 +6006,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5409,6 +6041,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -5420,6 +6075,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "seq-macro" @@ -5459,9 +6124,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5497,7 +6162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5571,7 +6236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", ] [[package]] @@ -5754,6 +6419,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "sysctl" version = "0.5.5" @@ -5782,6 +6459,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5864,6 +6551,47 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.1" @@ -5956,6 +6684,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5977,6 +6716,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -6035,6 +6780,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -6047,6 +6798,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -6075,6 +6832,53 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.26.1" @@ -6099,9 +6903,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -6114,9 +6918,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -6124,6 +6928,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -6339,7 +7153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -6376,10 +7190,19 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.9" +name = "webpki-root-certs" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -6518,7 +7341,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -6665,6 +7488,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6689,7 +7523,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -6698,16 +7532,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6725,31 +7550,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6767,101 +7575,52 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -6872,7 +7631,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -6918,21 +7677,70 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -7009,7 +7817,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.0" +version = "0.14.0" dependencies = [ "brotli", "flate2", @@ -7237,6 +8045,43 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "litemap", + "serde_core", + "yoke 0.8.3", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke 0.8.3", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zip" version = "7.2.0" @@ -7246,7 +8091,24 @@ dependencies = [ "crc32fast", "indexmap", "memchr", - "typed-path", + "typed-path 0.12.3", +] + +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path 0.13.0", + "windows-sys 0.61.2", + "zopfli", ] [[package]] @@ -7261,6 +8123,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -7325,3 +8199,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index c22395b8a..0686b502d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,15 +3,17 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } @@ -61,7 +63,7 @@ dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and h # formats quick-xml = "0.41" -miniz_oxide = "0.8" +miniz_oxide = "0.9" # codecs image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] } @@ -91,10 +93,13 @@ wgpu = { version = "30", default-features = false, features = ["std", "parking_l pollster = "1" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +# 0.37: `default_fonts` is no longer an egui default and the monochrome emoji faces moved behind +# `monochrome_emoji_fonts`. Every resolution root (native, CLI/headless, kittest, wasm) needs both: +# kittest's own dev-dependencies do not propagate to its consumers. +egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) # macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same) @@ -169,3 +174,21 @@ jpeg-decoder = { opt-level = 3 } jpeg-encoder = { opt-level = 3 } png = { opt-level = 3 } miniz_oxide = { opt-level = 3 } + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8b16eeba0..39eb49db2 100644 --- a/NOTICE +++ b/NOTICE @@ -12,7 +12,9 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in assets/ATTRIBUTION.md. -Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. +Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. The licence text is +assets/fonts/OFL-Inter.txt in the source tree; release packages ship it beside NOTICE as +OFL-Inter.txt (Windows installers include it in FONT-LICENCES.txt). Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md. crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the @@ -50,11 +52,15 @@ maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implement no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included. Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases) -also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md -(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans -Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto -Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's -licence as OFL-.txt; Windows installers include their texts in FONT-LICENCES.txt. +also embed the default fonts listed in +https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, +Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian, +NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2, Noto Sans Hebrew and +Noto Sans Devanagari), under the SIL Open Font License 1.1. The opt-in LXGW WenKai Lite face +(fonts/simplified-chinese-manifest.txt in craft-fonts) is not embedded. The web build embeds +BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular, and loads Noto Sans +CJK SC separately. Release packages include each font's licence as OFL-.txt; Windows +installers include their texts in FONT-LICENCES.txt. Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and are never distributed with it. On the user's request (Photo > Download Place Names) the app @@ -62,3 +68,270 @@ downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoName (https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/); attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own. + + +epaint_default_fonts, the egui default-font crate LightCraft builds with, also bundles Hack Regular +and Ubuntu Light, and Noto Emoji Regular when egui's `monochrome_emoji_fonts` feature is on (the +LightCraft workspace enables both `default_fonts` and `monochrome_emoji_fonts` on egui 0.37). The +LightCraft binaries embed these faces unmodified, registered through egui's default font definitions. +Source for all of them (the same pin as the icon fonts below): +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts + +Hack Regular (Hack-Regular.ttf) is Copyright (c) 2018 Source Foundry Authors and +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. It is licensed under the MIT License, and +the Bitstream Vera parts under the Bitstream Vera License with Reserved Font Names "Bitstream" and +"Vera": the font as a whole is MIT AND Bitstream Vera License. The work in the DejaVu project it +derives from was committed to the public domain. +The following text is reproduced verbatim from Hack-Regular.txt: + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + + +Ubuntu Light (Ubuntu-Light.ttf) is Copyright 2011 Canonical Ltd., was designed by Dalton Maag Ltd, +and is licensed under the Ubuntu Font Licence 1.0 (not the SIL Open Font License). +The following text is reproduced verbatim from UFL.txt; that file carries no copyright line, so the +copyright notice above is taken from the font's own name table: + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + + +Noto Emoji Regular (NotoEmoji-Regular.ttf) is Copyright 2013 Google Inc. All Rights Reserved. It was +designed by the Monotype Design Team and is licensed under the SIL Open Font License 1.1. +The following text is reproduced verbatim from OFL.txt; that file carries no copyright line, so the +copyright notice above is taken from the font's own name table: + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts +and registered through egui's default font definitions. They derive from emoji-icon-font by +John Slegers, under the MIT License. The subset and full face share the following upstream notice. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Original: https://github.com/jslegers/emoji-icon-font +The following text is reproduced verbatim from emoji-icon-font-mit-license.txt: + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 78b3b3234..71f30d4c2 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and diff --git a/README.zh-hans.md b/README.zh-hans.md index 2800db410..2c6ca8191 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -305,7 +305,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/README.zh-hant.md b/README.zh-hant.md index 12fcfb11b..ca624ce68 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -320,7 +320,7 @@ LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說, 在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/ Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。 - **最大的缺口:** - - **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣; + - **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣; - **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md); - **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法; - **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index a27e97beb..c1f9d258c 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -819,6 +819,7 @@ fn snapshot(args: &[String]) -> Result<(), String> { let services = lightcraft_ui_egui::Services { write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), write: Some(Box::new(lightcraft_engine::export::write_file)), + write_new: Some(Box::new(lightcraft_engine::export::write_file_new)), png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), diff --git a/apps/lightcraft-cli/tests/it/cli.rs b/apps/lightcraft-cli/tests/it/cli.rs index 1c8910d9d..8a11f5c9a 100644 --- a/apps/lightcraft-cli/tests/it/cli.rs +++ b/apps/lightcraft-cli/tests/it/cli.rs @@ -5,7 +5,7 @@ use std::process::{Command, Stdio}; use serde_json::{Value, json}; -const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli"); +use super::binary; #[test] fn contact_sheet_exports_pdf_and_protects_originals() { @@ -106,7 +106,7 @@ fn calibrate_reads_raf_inputs_and_reports_bad_files() { let input = tmp("invalid.RAF"); std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap(); let out = tmp("raf-profiles"); - let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap(); + let result = Command::new(binary()).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap(); assert!(!result.status.success()); let stderr = String::from_utf8_lossy(&result.stderr); assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}"); @@ -124,8 +124,13 @@ fn mean(path: &std::path::Path) -> f64 { fn mcp_over_stdio_sets_exposure_and_renders() { let input = tmp("in.png"); gradient_png(&input); - let mut child = - Command::new(BIN).args(["mcp", input.to_str().unwrap()]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut child = Command::new(binary()) + .args(["mcp", input.to_str().unwrap()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); let mut rpc = |id: u64, method: &str, params: Value| -> Value { @@ -163,9 +168,9 @@ fn render_subcommand() { gradient_png(&input); let plain = tmp("r-plain.png"); let out = tmp("r-out.jpg"); - let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); + let st = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); assert!(st.success()); - let st = Command::new(BIN) + let st = Command::new(binary()) .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1", "--size", "60"]) .status() .unwrap(); @@ -176,7 +181,7 @@ fn render_subcommand() { assert_eq!((d.width, d.height), (120, 80)); assert!(mean(&out) > mean(&plain) + 10.0); // Bad control id fails cleanly. - let o = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "nope=1"]).output().unwrap(); + let o = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "nope=1"]).output().unwrap(); assert!(!o.status.success()); assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control")); } @@ -204,10 +209,10 @@ fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() { ); assert!(ok, "{err}: {lines:?}"); let plain = tmp("lcpreset-plain.png"); - let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); + let st = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); assert!(st.success()); let bright = tmp("lcpreset-bright.png"); - let o = Command::new(BIN) + let o = Command::new(binary()) .args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()]) .output() .unwrap(); @@ -217,7 +222,7 @@ fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() { let other = tmp("other.lcpreset"); std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap(); let out = tmp("lcpreset-other.png"); - let o = Command::new(BIN) + let o = Command::new(binary()) .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()]) .output() .unwrap(); @@ -236,8 +241,10 @@ fn render_refuses_to_overwrite_its_input() { std::fs::create_dir_all(dir.join("sub")).unwrap(); let other_spelling = dir.join("sub/../self-in.png"); for out in [&input, &other_spelling] { - let o = - Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap(); + let o = Command::new(binary()) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]) + .output() + .unwrap(); assert!(!o.status.success()); assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr)); } @@ -249,7 +256,7 @@ fn render_export_options() { let input = tmp("o-in.png"); gradient_png(&input); let out = tmp("o-out.tif"); - let st = Command::new(BIN) + let st = Command::new(binary()) .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()]) .args(["--opt", "shortEdge=40", "--opt", "colorSpace=displayP3", "--opt", "bitDepth=16"]) .status() @@ -260,7 +267,7 @@ fn render_export_options() { assert_eq!(d.space.named, Some(lightcraft_codecs::NamedSpace::DisplayP3)); // original + sidecar named after the output let out = tmp("o-copy.png"); - let st = Command::new(BIN) + let st = Command::new(binary()) .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1", "--opt", "format=original"]) .status() .unwrap(); @@ -269,26 +276,26 @@ fn render_export_options() { let xmp = std::fs::read_to_string(out.with_extension("xmp")).unwrap(); assert!(xmp.contains("exposure"), "{xmp}"); // unknown extension fails with a hint - let o = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", tmp("x.bmp").to_str().unwrap()]).output().unwrap(); + let o = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", tmp("x.bmp").to_str().unwrap()]).output().unwrap(); assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("unknown extension")); } #[test] fn commands_subcommand_lists_registry() { - let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap(); + let o = Command::new(binary()).args(["commands", "--json"]).output().unwrap(); assert!(o.status.success()); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); for id in ["develop.set", "photo.rate", "library.import", "edit.undo", "app.export"] { assert!(ids.contains(&id), "{id}"); } - let o = Command::new(BIN).arg("controls").output().unwrap(); + let o = Command::new(binary()).arg("controls").output().unwrap(); assert!(String::from_utf8_lossy(&o.stdout).contains("light.exposure")); } #[test] fn commands_subcommand_lists_commands_sorted_by_id() { - let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap(); + let o = Command::new(binary()).args(["commands", "--json"]).output().unwrap(); assert!(o.status.success()); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); let cmds = v.as_array().unwrap(); @@ -302,7 +309,7 @@ fn commands_subcommand_lists_commands_sorted_by_id() { let unsorted = ids.windows(2).find(|w| w[0] >= w[1]); assert!(unsorted.is_none(), "--json should list commands sorted by id, but {unsorted:?} are out of order"); - let o = Command::new(BIN).arg("commands").output().unwrap(); + let o = Command::new(binary()).arg("commands").output().unwrap(); assert!(o.status.success()); let text = String::from_utf8_lossy(&o.stdout); let lines: Vec<&str> = text.lines().collect(); @@ -338,7 +345,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { ), ) .unwrap(); - let o = Command::new(BIN) + let o = Command::new(binary()) .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", a.to_str().unwrap(), "--size", "640x400"]) .output() .unwrap(); @@ -353,7 +360,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { assert_eq!(mean(p) < mean(&a) - 2.0, dimmed, "{}", p.display()); } // no script: one settled screenshot, at 2× scale - let o = Command::new(BIN).args(["snapshot", "-o", a.to_str().unwrap(), "--size", "320x240", "--scale", "2"]).output().unwrap(); + let o = Command::new(binary()).args(["snapshot", "-o", a.to_str().unwrap(), "--size", "320x240", "--scale", "2"]).output().unwrap(); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); let d = lightcraft_codecs::decode(&std::fs::read(&a).unwrap(), Default::default()).unwrap(); assert_eq!((d.width, d.height), (640, 480)); @@ -375,7 +382,7 @@ fn snapshot_script_failure_exits_non_zero() { ) .unwrap(); let _ = std::fs::remove_file(&out); - let o = Command::new(BIN) + let o = Command::new(binary()) .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"]) .output() .unwrap(); @@ -400,7 +407,7 @@ fn snapshot_script_with_non_object_params_is_an_error() { let script = tmp("snap-bad-params.jsonl"); let out = tmp("snap-bad-params.png"); std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap(); - let o = Command::new(BIN) + let o = Command::new(binary()) .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"]) .output() .unwrap(); @@ -417,7 +424,7 @@ fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() { .unwrap(); for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] { let out = tmp(&format!("snap-ui-zoom-{scale}.png")); - let o = Command::new(BIN) + let o = Command::new(binary()) .args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale]) .output() .unwrap(); @@ -440,7 +447,7 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() { ) .unwrap(); let image = tmp("default-cpu.png"); - let output = Command::new(BIN) + let output = Command::new(binary()) .env_remove("LIGHTCRAFT_GPU") .env_remove("LIGHTCRAFT_GPU_BACKEND") .env_remove("WGPU_BACKEND") @@ -476,7 +483,7 @@ fn snapshot_starts_without_a_gpu() { .unwrap(); for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] { let out = tmp(&format!("nogpu-{var}.png")); - let o = Command::new(BIN) + let o = Command::new(binary()) .env_remove("LIGHTCRAFT_GPU") .env_remove("LIGHTCRAFT_GPU_BACKEND") .env(var, value) @@ -496,7 +503,7 @@ fn snapshot_starts_without_a_gpu() { } fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec, String) { - let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut child = Command::new(binary()).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); if let Some(text) = stdin { child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap(); } @@ -561,7 +568,7 @@ fn devices_are_listed_and_imported_from() { let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join("CARD/DCIM/100TEST")).unwrap(); gradient_png(&base.join("CARD/DCIM/100TEST/IMG_0001.png")); - let o = Command::new(BIN).env("LIGHTCRAFT_DEVICE_ROOTS", &base).args(["run", "--demo", "library.devices"]).output().unwrap(); + let o = Command::new(binary()).env("LIGHTCRAFT_DEVICE_ROOTS", &base).args(["run", "--demo", "library.devices"]).output().unwrap(); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap(); let dev = &line["result"][0]; @@ -570,7 +577,7 @@ fn devices_are_listed_and_imported_from() { let dcim = dev["path"].as_str().unwrap().to_string(); // JSON-escaped: a Windows path has backslashes let paths = serde_json::to_string(&[&dcim]).unwrap(); - let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap(); + let o = Command::new(binary()).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap(); let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap(); assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}"); } @@ -583,7 +590,7 @@ fn a_library_open_in_another_process_is_refused() { let _ = std::fs::remove_dir_all(&lib); let lib_s = lib.to_str().unwrap(); let mut holder = - Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap(); + Command::new(binary()).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap(); let mut err = BufReader::new(holder.stderr.take().unwrap()); let mut line = String::new(); while !line.contains("opened library") { @@ -643,7 +650,7 @@ fn warnings_are_logged_on_stderr() { gradient_png(&input); let out = tmp("log-out.jpg"); let run = |level: &str| { - let o = Command::new(BIN) + let o = Command::new(binary()) .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()]) .env("LIGHTCRAFT_GPU_BACKEND", "bogus") .env("RUST_LOG", level) @@ -667,7 +674,7 @@ fn snapshot_rejects_invalid_dimensions_before_creating_an_image() { [("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate() { let image = tmp(&format!("invalid-viewport-{index}.png")); - let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap(); + let output = Command::new(binary()).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap(); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr)); assert!(!image.exists()); @@ -677,8 +684,12 @@ fn snapshot_rejects_invalid_dimensions_before_creating_an_image() { #[test] fn snapshot_preserves_fractional_scale_rounding() { let image = tmp("fractional-scale-rounding.png"); - let output = - Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap(); + let output = Command::new(binary()) + .env("LIGHTCRAFT_GPU", "0") + .args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]) + .arg(&image) + .output() + .unwrap(); assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap(); assert_eq!((decoded.width, decoded.height), (311, 180)); @@ -709,7 +720,7 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() { .unwrap(); for gpu_environment in [None, Some("1")] { let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default"))); - let mut cmd = Command::new(BIN); + let mut cmd = Command::new(binary()); cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND"); if let Some(value) = gpu_environment { cmd.env("LIGHTCRAFT_GPU", value); @@ -770,13 +781,14 @@ fn denoise_serve_runs_tiles_for_another_computer() { let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap(); // without its token the server doesn't start - let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap(); + let o = + Command::new(binary()).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap(); assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR")); let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); let addr = format!("127.0.0.1:{port}"); let token = "cli-test-token-0123456789"; - let mut child = Command::new(BIN) + let mut child = Command::new(binary()) .args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"]) .env("LIGHTCRAFT_DENOISE_TOKEN", token) .stderr(Stdio::piped()) diff --git a/apps/lightcraft-cli/tests/it/main.rs b/apps/lightcraft-cli/tests/it/main.rs index 0c07c6438..596ffb21d 100644 --- a/apps/lightcraft-cli/tests/it/main.rs +++ b/apps/lightcraft-cli/tests/it/main.rs @@ -2,3 +2,8 @@ mod cli; mod mcp_shutdown; + +/// Nextest archives relocate the executable; plain Cargo uses its compile-time path. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_lightcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_lightcraft-cli").into()) +} diff --git a/apps/lightcraft-cli/tests/it/mcp_shutdown.rs b/apps/lightcraft-cli/tests/it/mcp_shutdown.rs index 29d1ace93..0237b6b37 100644 --- a/apps/lightcraft-cli/tests/it/mcp_shutdown.rs +++ b/apps/lightcraft-cli/tests/it/mcp_shutdown.rs @@ -6,13 +6,7 @@ use std::time::{Duration, Instant}; #[test] fn broken_stdout_exits_with_stdin_still_open() { - let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli")) - .arg("mcp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); + let mut child = Command::new(super::binary()).arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); let mut input = child.stdin.take().unwrap(); drop(child.stdout.take()); input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap(); diff --git a/apps/lightcraft-web/Cargo.toml b/apps/lightcraft-web/Cargo.toml index aaa14a648..d4ae0f667 100644 --- a/apps/lightcraft-web/Cargo.toml +++ b/apps/lightcraft-web/Cargo.toml @@ -77,3 +77,6 @@ web-sys = { workspace = true, features = [ # it (HEVC patents); build with `--features heif` to include it. On wasm32 panics abort, so # heic-rs's `catch_unwind` guard does not apply there. heif = ["lightcraft-codecs/heif"] + +[dev-dependencies] +egui = { workspace = true } diff --git a/apps/lightcraft-web/src/bench.rs b/apps/lightcraft-web/src/bench.rs index b1b976bfa..484c0041f 100644 --- a/apps/lightcraft-web/src/bench.rs +++ b/apps/lightcraft-web/src/bench.rs @@ -39,7 +39,7 @@ pub struct Bench { } impl Bench { - /// `start_ms`: the page's time origin in the same clock as [`lightcraft_ui_egui::now_ms`]. + /// `start_ms`: an origin on the same monotonic clock as [`lightcraft_ui_egui::monotonic_ms`]. pub fn new(start_ms: f64) -> Self { Bench { phase: Phase::Idle, @@ -54,13 +54,16 @@ impl Bench { } } + /// The render key of the main preview texture, or `None` while that slot holds no texture. + /// A changed key means the benchmark's requested main-image render has arrived. fn main_key(app: &LightcraftApp) -> Option { app.renderer.textures.get(&Slot::Main).map(|t| t.key) } - /// Call once per frame after `LightcraftApp::logic`. Returns the report when finished. + /// Call once per root UI pass after `LightcraftApp::ui` (a UI-phase step, never a hidden tick). + /// Returns the report when finished. pub fn step(&mut self, app: &mut LightcraftApp) -> Option { - let now = lightcraft_ui_egui::now_ms(); + let now = lightcraft_ui_egui::monotonic_ms(); self.frames += 1; if self.first_frame_ms.is_none() && self.frames > 1 { self.first_frame_ms = Some(now - self.start_ms); diff --git a/apps/lightcraft-web/src/drop_gate.rs b/apps/lightcraft-web/src/drop_gate.rs new file mode 100644 index 000000000..f21efced4 --- /dev/null +++ b/apps/lightcraft-web/src/drop_gate.rs @@ -0,0 +1,391 @@ +//! When the browser host may consume queued drops. Kept out of the wasm-only `web` module so the +//! rule that keeps stored drops out of a library behind a foreground window is testable natively. + +use lightcraft_ui_egui::LightcraftApp; + +/// The browser host's two drop queues: file handles not yet read, and originals whose asynchronous +/// storage has finished and that wait to be imported. Taking either only through this trait keeps +/// the gate and the drain together. +pub trait QueuedDrops { + type Dropped; + type Pending; + /// Whether a prompt or the blocking library window owns input right now. + fn drops_wait(&self) -> bool; + /// Record that all currently unread handles are held by this foreground barrier. + fn defer_dropped(&mut self); + fn take_dropped(&mut self) -> Vec; + /// Take fresh arrivals only, retaining handles protected by an earlier foreground barrier. + fn take_unprotected_dropped(&mut self) -> Vec; + fn take_pending(&mut self) -> Vec; +} + +/// A prompt or the blocking library window (over an already open library, or its own New Library +/// name prompt or asynchronous folder picker) owns input: leaves the dropped-file queue and the +/// originals still being stored for a later UI pass, instead of importing them behind the window. +/// With no library open and no dialog in front, the existing drop path is unchanged. +pub fn drops_wait(app: &LightcraftApp) -> bool { + lightcraft_ui_egui::panels::notices::blocks_input(app) || lightcraft_ui_egui::panels::library_problem::blocks_file_drops(app) +} + +/// One UI pass's queues, drained by [`drain_ready`]. +pub struct Drained { + pub dropped: Vec, + /// `None` when new file handles were refused behind a dialog: the originals already stored + /// stay queued for a later pass (as before), rather than importing behind that dialog. + pub pending: Option>, +} + +/// The actual queue drain `WebApp` runs each UI pass: while [`drops_wait`] holds, neither queue is +/// touched. With an ordinary dialog open, only fresh handles are taken for refusal; earlier +/// barrier-held handles remain queued. If fresh handles are refused, stored originals are left +/// queued as well. Otherwise the existing stored-original drain policy is unchanged. +pub fn drain_ready(queues: &mut Q, dialog_open: bool) -> Option> { + if queues.drops_wait() { + queues.defer_dropped(); + return None; + } + let dropped = if dialog_open { queues.take_unprotected_dropped() } else { queues.take_dropped() }; + let pending = if !dropped.is_empty() && dialog_open { None } else { Some(queues.take_pending()) }; + Some(Drained { dropped, pending }) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::{QueuedDrops, drain_ready, drops_wait}; + use lightcraft_engine::Session; + use lightcraft_ui_egui::panels::library_problem::LibraryProblem; + use lightcraft_ui_egui::state::Dialog; + use lightcraft_ui_egui::{LightcraftApp, Services}; + + struct Scratch(std::path::PathBuf); + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + type Asked = std::sync::Arc>>>>; + + fn app_with_library(tag: &str) -> Result<(Scratch, LightcraftApp), String> { + let dir = std::env::temp_dir().join(format!("lc-web-drop-gate-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let scratch = Scratch(dir); + let mut session = Session::new().with_fs(); + session.open_library(&scratch.0, false).map_err(|error| error.to_string())?; + Ok((scratch, LightcraftApp::new(session, Services::default()))) + } + + /// Stands in for `WebApp`: the same shared orchestration over two real queues, so a test sees + /// that a held window leaves both queues untouched, that the ready path drains both, and that + /// a refused new drop keeps the already stored originals queued. + struct FakeQueues { + app: LightcraftApp, + dropped: Vec, + protected: usize, + pending: Vec, + } + + impl FakeQueues { + fn new(app: LightcraftApp) -> Self { + Self { app, dropped: vec!["dropped-photo".into()], protected: 0, pending: vec!["web/hash/photo.png".into()] } + } + } + + impl QueuedDrops for FakeQueues { + type Dropped = String; + type Pending = String; + + fn drops_wait(&self) -> bool { + drops_wait(&self.app) + } + + fn defer_dropped(&mut self) { + self.protected = self.dropped.len(); + } + + fn take_dropped(&mut self) -> Vec { + self.protected = 0; + std::mem::take(&mut self.dropped) + } + + fn take_unprotected_dropped(&mut self) -> Vec { + self.dropped.split_off(self.protected.min(self.dropped.len())) + } + + fn take_pending(&mut self) -> Vec { + std::mem::take(&mut self.pending) + } + } + + #[test] + fn stored_drops_wait_through_the_choosers_new_library_prompt_and_drain_after_it() { + let (_dir, mut app) = app_with_library("chooser").expect("create chooser test library"); + let mut problem = LibraryProblem::choose_later(&app.ui.settings); + if let Some(c) = problem.chooser.as_mut() { + c.over = Some(app.session.library_generation()); + } + app.library_problem = Some(problem); + let mut queues = FakeQueues::new(app); + + // The list's own New Library name prompt hides the list but still owns the library. + queues.app.ui.dialog = Some(Dialog::TextPrompt { + title: "New Library".into(), + hint: "Library name".into(), + value: String::new(), + command: "app.newLibrary".into(), + params: serde_json::json!({}), + key: "name".into(), + }); + assert!(drain_ready(&mut queues, false).is_none(), "the name prompt holds both queues"); + assert_eq!((queues.dropped.len(), queues.pending.len()), (1, 1), "the queued drop survived the prompt"); + + // Cancelling the prompt brings the list back; it still owns the drop until it goes. + queues.app.ui.dialog = None; + assert!(drain_ready(&mut queues, false).is_none(), "the list is back and still holds the queue"); + queues.app.library_problem = None; + let got = drain_ready(&mut queues, false).expect("the queues should be ready"); + assert_eq!(got.dropped, vec!["dropped-photo".to_string()]); + assert_eq!(got.pending, Some(vec!["web/hash/photo.png".to_string()])); + assert!(queues.dropped.is_empty() && queues.pending.is_empty(), "both queues drained once the window was gone"); + } + + #[test] + fn a_refused_new_drop_behind_a_dialog_keeps_stored_originals_queued() { + let (_dir, app) = app_with_library("refused").expect("create refused-drop test library"); + // not held by the gate: the dialog alone refuses the newly dropped handles + let mut queues = FakeQueues::new(app); + let got = drain_ready(&mut queues, true).expect("the queues should be ready"); + assert_eq!(got.dropped, vec!["dropped-photo".to_string()]); + assert_eq!(got.pending, None, "the refused drop must not drain originals behind the dialog"); + assert_eq!(queues.pending.len(), 1, "the earlier originals stayed queued"); + + // Nothing new was dropped: originals read from an earlier drop still finish, as before. + let got = drain_ready(&mut queues, true).expect("the queues should be ready"); + assert!(got.dropped.is_empty()); + assert_eq!(got.pending, Some(vec!["web/hash/photo.png".to_string()])); + assert!(queues.pending.is_empty()); + } + + #[test] + fn a_no_library_library_picker_holds_both_queues() { + let mut app = LightcraftApp::new(Session::new().with_fs(), Services::default()); + let asked: Asked = Default::default(); + app.services.picker = Some(Box::new(move |_| { + let (tx, rx) = std::sync::mpsc::channel(); + asked.lock().unwrap().push(tx); + Ok(rx) + })); + // New Library's name prompt was confirmed; the asynchronous place picker is still open. + assert_eq!(app.run("app.newLibrary", serde_json::json!({"name": "Queued"})).unwrap(), serde_json::Value::Null); + assert!(app.session.library.is_none()); + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut queues = FakeQueues::new(app); + assert!(drain_ready(&mut queues, false).is_none(), "a library picker that will open one holds both queues"); + assert_eq!((queues.dropped.len(), queues.pending.len()), (1, 1), "nothing was drained while the picker is up"); + } + + #[test] + fn a_no_library_window_lets_the_existing_drop_path_consume_the_queues() { + let mut app = LightcraftApp::new(Session::with_demo(), Services::default()); + app.library_problem = Some(LibraryProblem::new("/no/such/library", "missing")); + let mut queues = FakeQueues::new(app); + assert!(!drops_wait(&queues.app), "with no library open the existing drop path is unchanged"); + let got = drain_ready(&mut queues, false).expect("the queues should be ready"); + assert_eq!(got.dropped, vec!["dropped-photo".to_string()]); + assert_eq!(got.pending, Some(vec!["web/hash/photo.png".to_string()])); + assert!(queues.dropped.is_empty() && queues.pending.is_empty(), "the host consumed both queues"); + + queues.app.notices.push("The settings file could not be read and was reset.".into()); + queues.dropped.push("late-drop".into()); + queues.pending.push("web/hash/late.png".into()); + assert!(drain_ready(&mut queues, false).is_none(), "a prompt still owns input"); + assert_eq!((queues.dropped.len(), queues.pending.len()), (1, 1), "the prompt left both queues alone"); + } + + /// Uses the product's actual raw-handle queue and provenance APIs. Only the stored-original + /// vector is host-owned; there is no test copy of protected-prefix bookkeeping or policy. + struct RealAppQueues { + h: lightcraft_ui_egui::headless::Headless, + pending: Vec, + pending_takes: usize, + } + + impl QueuedDrops for RealAppQueues { + type Dropped = egui::DroppedFileHandle; + type Pending = String; + + fn drops_wait(&self) -> bool { + drops_wait(&self.h.app) + } + + fn defer_dropped(&mut self) { + self.h.app.defer_dropped_files(); + } + + fn take_dropped(&mut self) -> Vec { + self.h.app.take_dropped_files() + } + + fn take_unprotected_dropped(&mut self) -> Vec { + self.h.app.take_unprotected_dropped_files() + } + + fn take_pending(&mut self) -> Vec { + self.pending_takes += 1; + std::mem::take(&mut self.pending) + } + } + + #[derive(Debug)] + struct UnreadDrop { + path: std::path::PathBuf, + reads: std::sync::Arc, + } + + impl egui::DroppedFile for UnreadDrop { + fn path(&self) -> &std::path::Path { + &self.path + } + + fn bytes(&self) -> Result, String> { + self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("the queue gate must not read dropped bytes".into()) + } + } + + fn unread(path: &str) -> (egui::DroppedFileHandle, std::sync::Arc) { + let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let handle = std::sync::Arc::new(UnreadDrop { path: path.into(), reads: reads.clone() }); + (handle, reads) + } + + fn queue_raw(queues: &mut RealAppQueues, handles: &[egui::DroppedFileHandle]) { + let mut raw = egui::RawInput { dropped_files: handles.to_vec(), ..Default::default() }; + queues.h.app.raw_input_hook(&queues.h.view.ctx, &mut raw); + assert!(raw.dropped_files.is_empty(), "the product takes raw ownership once"); + } + + fn hold_library(queues: &mut RealAppQueues) -> Result<(), String> { + let mut problem = LibraryProblem::choose_later(&queues.h.app.ui.settings); + problem.chooser.as_mut().ok_or("the launch list has no chooser")?.over = Some(queues.h.app.session.library_generation()); + queues.h.app.library_problem = Some(problem); + Ok(()) + } + + fn real_queues(tag: &str) -> Result<(Scratch, RealAppQueues), String> { + let (dir, app) = app_with_library(tag)?; + let h = lightcraft_ui_egui::headless::Headless::new(app, [1300.0, 900.0], 1.0); + Ok((dir, RealAppQueues { h, pending: vec!["web/hash/stored.png".into()], pending_takes: 0 })) + } + + fn protect(queues: &mut RealAppQueues, handle: &egui::DroppedFileHandle) -> Result<(), String> { + hold_library(queues)?; + queue_raw(queues, std::slice::from_ref(handle)); + // WebApp's gate runs before App.ui, including the cold first frame. The gate + // itself must establish provenance; a warmed native UI pass cannot stand in for it. + assert_eq!(queues.h.frames(), 0, "this starts before the first native UI pass"); + assert!(drops_wait(&queues.h.app)); + assert!(drain_ready(queues, false).is_none()); + assert_eq!(queues.h.frames(), 0, "the host gate protects ownership without drawing"); + assert_eq!(queues.pending_takes, 0); + assert!(queues.h.app.scan.is_none() && queues.h.app.import.is_none()); + assert_eq!(queues.h.app.session.catalog.len(), 0); + Ok(()) + } + + #[test] + fn real_unread_barrier_drop_survives_a_dialog_while_stored_originals_finish() { + let (_dir, mut queues) = real_queues("real-held-dialog").expect("create real-queue library"); + let (a, reads) = unread("A.png"); + protect(&mut queues, &a).expect("hold the library barrier"); + queues.h.app.library_problem = None; + queues.h.app.ui.dialog = Some(Dialog::WhatsNew); + // WebApp drains BEFORE App.ui. A native UI pass here would conceal the host-order bug. + let got = drain_ready(&mut queues, true).expect("only the ordinary dialog remains"); + assert!(got.dropped.is_empty(), "the earlier unread A must not be drained for dialog refusal"); + assert_eq!( + got.pending, + Some(vec!["web/hash/stored.png".into()]), + "with no fresh refusal, earlier stored originals retain their existing completion policy" + ); + assert_eq!(queues.pending_takes, 1); + let again = drain_ready(&mut queues, true).unwrap(); + assert!(again.dropped.is_empty(), "A stays protected across another host pass"); + queues.h.app.ui.dialog = None; + let released = drain_ready(&mut queues, false).unwrap(); + assert_eq!(released.dropped.len(), 1); + assert!(std::sync::Arc::ptr_eq(&released.dropped[0], &a), "the original A handle is released"); + assert!(drain_ready(&mut queues, false).unwrap().dropped.is_empty(), "A transfers exactly once"); + assert!(queues.h.app.take_dropped_files().is_empty()); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + #[test] + fn real_fresh_dialog_drop_is_refused_without_consuming_protected_or_stored_files() { + let (_dir, mut queues) = real_queues("real-mixed-dialog").expect("create real-queue library"); + let (a, reads_a) = unread("A.png"); + let (b, reads_b) = unread("B.png"); + protect(&mut queues, &a).expect("hold the library barrier"); + queues.h.app.library_problem = None; + queues.h.app.ui.dialog = Some(Dialog::WhatsNew); + queue_raw(&mut queues, std::slice::from_ref(&b)); + let got = drain_ready(&mut queues, true).unwrap(); + assert_eq!(got.dropped.len(), 1, "only fresh B is offered to the browser's existing refusal branch"); + assert!(std::sync::Arc::ptr_eq(&got.dropped[0], &b)); + assert_eq!(got.pending, None, "refusing B must not consume stored originals"); + assert_eq!(queues.pending_takes, 0); + assert_eq!(queues.pending, vec!["web/hash/stored.png".to_string()]); + assert!(matches!(queues.h.app.ui.dialog, Some(Dialog::WhatsNew))); + queues.h.app.ui.dialog = None; + let released = drain_ready(&mut queues, false).unwrap(); + assert_eq!(released.dropped.len(), 1, "protected A survived B's refusal"); + assert!(std::sync::Arc::ptr_eq(&released.dropped[0], &a)); + assert_eq!(released.pending, Some(vec!["web/hash/stored.png".into()])); + let empty = drain_ready(&mut queues, false).unwrap(); + assert!(empty.dropped.is_empty()); + assert_eq!(empty.pending, Some(Vec::::new())); + assert_eq!(reads_a.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(reads_b.load(std::sync::atomic::Ordering::SeqCst), 0); + // Consuming A clears its protection: a later ordinary drop is still fresh. + let (c, reads_c) = unread("C.png"); + queues.h.app.ui.dialog = Some(Dialog::WhatsNew); + queue_raw(&mut queues, std::slice::from_ref(&c)); + let fresh = drain_ready(&mut queues, true).unwrap(); + assert_eq!(fresh.dropped.len(), 1); + assert!(std::sync::Arc::ptr_eq(&fresh.dropped[0], &c)); + assert_eq!(fresh.pending, None); + queues.h.app.ui.dialog = None; + assert!(drain_ready(&mut queues, false).unwrap().dropped.is_empty()); + assert_eq!(reads_c.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + #[test] + fn real_protected_drops_keep_order_through_barrier_reentry_and_transfer_once() { + let (_dir, mut queues) = real_queues("real-reentered").expect("create real-queue library"); + let (a, reads_a) = unread("A.png"); + let (b, reads_b) = unread("B.png"); + protect(&mut queues, &a).expect("hold the library barrier"); + queues.h.app.library_problem = None; + queues.h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(drain_ready(&mut queues, true).unwrap().dropped.is_empty()); + queues.h.app.ui.dialog = None; + hold_library(&mut queues).expect("reenter the library barrier"); // Before a pass can consume A. + queue_raw(&mut queues, std::slice::from_ref(&b)); + assert!(drain_ready(&mut queues, false).is_none(), "the reentered host gate protects B before native UI"); + assert_eq!(queues.h.frames(), 0, "neither protected arrival depends on font readiness or a completed UI pass"); + queues.h.app.library_problem = None; + queues.h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(drain_ready(&mut queues, true).unwrap().dropped.is_empty(), "both real barrier arrivals remain protected"); + queues.h.app.ui.dialog = None; + let released = drain_ready(&mut queues, false).unwrap(); + assert_eq!(released.dropped.len(), 2); + assert!(std::sync::Arc::ptr_eq(&released.dropped[0], &a)); + assert!(std::sync::Arc::ptr_eq(&released.dropped[1], &b)); + assert!(drain_ready(&mut queues, false).unwrap().dropped.is_empty()); + assert!(queues.h.app.take_dropped_files().is_empty()); + assert_eq!(reads_a.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(reads_b.load(std::sync::atomic::Ordering::SeqCst), 0); + } +} diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index f984cb34c..5d3c01158 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -19,6 +19,10 @@ pub mod automation; pub mod backup; pub mod bench; +// The web host's drop gate, also compiled natively so its queue handling has tests (the module is +// unused in a native, non-test library build and would otherwise be dead code there). +#[cfg(any(test, target_arch = "wasm32"))] +mod drop_gate; pub mod files; pub mod store; pub mod wire; diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 827b367f8..6e9e85596 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -168,6 +168,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { Ok(()) } +/// Build the browser host's `Services`: library backup/restore through browser storage and a +/// download, asynchronous file picking, downloads for writes, and PNG encoding. Desktop-only +/// services (native pickers, reveal, model folders) stay unset. fn services(originals: Originals, backend: Option, files: Files, frozen: Rc>, ctx: egui::Context) -> Services { let (backup_backend, backup_files) = (backend.clone(), files); let restore_backend = backend.clone(); @@ -228,7 +231,12 @@ fn services(originals: Originals, backend: Option, files: Files, frozen save_curve_preset_file: None, pick_slideshow_templates: None, save_slideshow_template: None, + pick_print_templates: None, + save_print_template: None, write: Some(Box::new(download)), + // the browser download path has no meaningful create-new file write; the control channel + // (and therefore screenshot paths) is not available on the web + write_new: None, // downloads happen on the main thread: exports run in the foreground on the web write_shared: None, png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { @@ -271,7 +279,7 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc, } +/// The browser host's drop queues, taken only through the shared gate: a prompt or blocking +/// library window leaves both untouched for a later UI pass. +impl crate::drop_gate::QueuedDrops for WebApp { + type Dropped = egui::DroppedFileHandle; + type Pending = String; + + fn drops_wait(&self) -> bool { + crate::drop_gate::drops_wait(&self.app) + } + + fn defer_dropped(&mut self) { + self.app.defer_dropped_files(); + } + + fn take_dropped(&mut self) -> Vec { + self.app.take_dropped_files() + } + + fn take_unprotected_dropped(&mut self) -> Vec { + self.app.take_unprotected_dropped_files() + } + + fn take_pending(&mut self) -> Vec { + self.originals.take_pending() + } +} + impl WebApp { + /// Construct the web app from the boot options: install originals and open the library from + /// browser storage (a read failure starts an empty session and a blocking `LibraryProblem`), + /// load the stored UI state, start render workers and the optional benchmark. fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec) -> Self { let Boot { opts, backend, files, index, lib_dir, load_error } = boot; let originals = Originals::default(); @@ -470,7 +508,12 @@ impl WebApp { let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone())) .with_chinese_font(chinese_font); let ui_written = files.get("ui.json").unwrap_or_default(); - if let Ok(ui) = serde_json::from_slice::(&ui_written) { + // an arrangement from an older docking version starts again from the default + let mut saved = serde_json::from_slice::(&ui_written).unwrap_or_default(); + if let Some(fields) = saved.as_object_mut() { + lightcraft_ui_egui::docking::drop_outdated(fields); + } + if let Ok(ui) = serde_json::from_value::(saved) { app.ui = ui; } app.ui = app.ui.sanitized(); @@ -496,7 +539,7 @@ impl WebApp { } log::info!("lightcraft: {n} render workers"); CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone())); - let origin = lightcraft_ui_egui::now_ms() - perf_now(); + let origin = lightcraft_ui_egui::monotonic_ms(); WebApp { app, originals, @@ -515,25 +558,31 @@ impl WebApp { } } + /// Consume the drops the raw-input hook queued once (never re-read `raw.dropped_files` in a + /// hidden logic tick), and add originals whose bytes have arrived. fn import_dropped(&mut self, ctx: &egui::Context) { - let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + // A prompt or the blocking library window (including the chooser's own New Library name + // prompt or asynchronous folder picker) holds both queues for a later UI pass: nothing is + // read or imported behind it. `drain_ready` is the same orchestration the native tests run. + let dialog_open = self.app.ui.dialog.is_some(); + let Some(drained) = crate::drop_gate::drain_ready(self, dialog_open) else { return }; // a dialog is on screen (the card said so while the files hovered): nothing is read or imported // (files read from an earlier drop still finish below) - if !dropped.is_empty() && self.app.ui.dialog.is_some() { + if !drained.dropped.is_empty() && dialog_open { self.app.toast(ctx, lightcraft_ui_egui::i18n::tr("Close the open dialog, then drop the files again")); return; } - for f in dropped { + for f in drained.dropped { let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { - let name = f.path().to_string_lossy().to_string(); - match f.bytes_async().await { + let name = egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string(); + match egui::DroppedFile::bytes_async(f.as_ref()).await { Ok(bytes) => store_file(originals, backend, name, bytes, ctx).await, Err(e) => notice(lightcraft_ui_egui::i18n::dropped_file_unreadable(&name, &e.to_string())), } }); } - let paths = self.originals.take_pending(); + let Some(paths) = drained.pending else { return }; if !paths.is_empty() { match self.app.run("library.import", json!({"paths": paths})) { // what the import did, not what was submitted: duplicates and unsupported files add nothing, @@ -587,7 +636,7 @@ impl WebApp { if self.frozen.get() { return; } - let now = perf_now(); + let now = lightcraft_ui_egui::monotonic_ms(); if now - self.last_save >= SAVE_EVERY_MS { self.last_save = now; self.app.session.save_view(); @@ -677,13 +726,14 @@ impl WebApp { } impl eframe::App for WebApp { + /// eframe's non-drawing pass for the browser host: drain safety notices and queued inbox work, + /// load originals the UI requested, apply the cache budget, run the app's logic, then save. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { for text in safety::take_notices() { - let now = ctx.input(|i| i.time); + let now = self.app.tick_now(); self.app.ui.toast = Some((text, now + NOTICE_SECS, None)); } self.run_inbox(); - self.import_dropped(ctx); self.load_originals(ctx); if let Some(w) = &self.workers { // `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on @@ -692,26 +742,22 @@ impl eframe::App for WebApp { } self.app.logic(ctx); self.save(); - if let Some(b) = self.bench.as_mut() - && let Some(report) = b.step(&mut self.app) - { - log::info!("lightcraft-bench {report}"); - if let Some((alive, ready, remote, inline)) = self.workers.as_ref().map(Workers::stats) { - log::info!("lightcraft-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}"); - } - set_status(&format!("bench: {report}")); - self.bench = None; - } - if self.bench.is_some() { - ctx.request_repaint(); - } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + /// Pass the browser's raw input to the app hook before egui processes it, so prompt barriers + /// and synthetic or dropped input are applied consistently with the desktop host. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(ctx, raw); } + /// eframe's UI pass: import queued drops unless a prompt blocks input, draw the app, update the + /// page title, and advance the benchmark once per root pass. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + // Drops are the UI phase's: a hidden `logic`/`run_logic` tick must not import them twice. + // `import_dropped` itself takes both queues only through the gate, so a prompt or the + // blocking library window (over an already open library) keeps a stored drop queued. + let ctx = ui.ctx().clone(); + self.import_dropped(&ctx); self.app.ui(ui); // eframe's web runner ignores `ViewportCommand::Title`: name the open photo in the page title let title = self.app.page_title(self.files.is_dirty() || self.flushing.get()); @@ -721,6 +767,22 @@ impl eframe::App for WebApp { doc.set_title(&title); self.title = title; } + // The benchmark is a UI-phase step: a second layout pass or a hidden tick must not count a frame. + if ui.ctx().current_pass_index() == 0 { + if let Some(b) = self.bench.as_mut() + && let Some(report) = b.step(&mut self.app) + { + log::info!("lightcraft-bench {report}"); + if let Some((alive, ready, remote, inline)) = self.workers.as_ref().map(Workers::stats) { + log::info!("lightcraft-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}"); + } + set_status(&format!("bench: {report}")); + self.bench = None; + } + if self.bench.is_some() { + ctx.request_repaint(); + } + } if !self.first_frame_logged && !self.app.widgets.is_empty() { self.first_frame_logged = true; log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now()); diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 95e080974..d00bc41e9 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -23,7 +23,9 @@ lightcraft-sysmem = { workspace = true } lightcraft-ui-egui = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raster = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] } +# `clipboard` is a separate eframe feature in 0.37: without it egui-winit simulates an in-process +# clipboard and cut/copy/paste, RequestPaste and the Wayland PRIMARY path are unavailable. +eframe = { workspace = true, features = ["default_fonts", "clipboard", "wgpu", "glow", "accesskit", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } @@ -53,3 +55,6 @@ objc2-foundation = { workspace = true } [dev-dependencies] # tests record why they skip (`cargo xtask ci` lists the skips) lightcraft-testkit = { workspace = true } + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/lightcraft/src/dialog_filter.rs b/apps/lightcraft/src/dialog_filter.rs index 87760f799..27c3fd704 100644 --- a/apps/lightcraft/src/dialog_filter.rs +++ b/apps/lightcraft/src/dialog_filter.rs @@ -133,13 +133,23 @@ mod tests { } } - /// Every dialog filter goes through the helper: `main.rs` must not call rfd's own + /// Every dialog filter goes through the helper: no source file in `src/` calls rfd's own /// `add_filter`. #[test] fn no_dialog_bypasses_the_helper() { - for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] { - assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase"); + let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + let mut helper_used = false; + while let Some(dir) = dirs.pop() { + for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") && !path.ends_with("dialog_filter.rs") { + let src = std::fs::read_to_string(&path).unwrap(); + assert!(!src.contains(".add_filter("), "{} calls rfd's add_filter; use add_filter_nocase", path.display()); + helper_used |= src.contains(".add_filter_nocase("); + } + } } - assert!(include_str!("main.rs").contains(".add_filter_nocase(")); + assert!(helper_used); } } diff --git a/apps/lightcraft/src/gpu_crash.rs b/apps/lightcraft/src/gpu_crash.rs new file mode 100644 index 000000000..1656d88e3 --- /dev/null +++ b/apps/lightcraft/src/gpu_crash.rs @@ -0,0 +1,97 @@ +use super::prefs::config_dir; +use super::*; + +/// 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: +/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12 +/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471). +/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`. +#[cfg(test)] +pub(crate) fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { + window_gpu::base_options() +} + +/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions +/// and `LIGHTCRAFT_NO_PREFS`. +pub(crate) fn window_files(in_memory: bool) -> window_gpu::Files { + window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none())) +} + +/// Written while the GPU compute device is created, removed once that returned +/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). +fn gpu_marker_path() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("gpu-init.marker")) +} + +/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU +/// driver while creating the device), the notice to show — GPU rendering then starts off. +/// +/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would +/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU +/// starts off there too, and the next ordinary launch reports and clears it. +pub(crate) fn gpu_crash_check(in_memory: bool) -> Option { + gpu_crash_check_at(gpu_marker_path(), in_memory) +} + +pub(crate) fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { + use lightcraft_engine::gpu::backend; + let left = if in_memory { + marker.as_deref().and_then(backend::read_init_marker) + } else { + let left = marker.as_deref().and_then(backend::take_init_marker); + backend::set_init_marker(marker); + left + }; + left.map(|what| gpu_crash_notice(&what)) +} + +pub(crate) fn gpu_crash_notice(what: &str) -> String { + format!( + "LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \ + To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \ + LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)." + ) +} + +/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next +/// launch renders on the CPU and says why. +pub(crate) fn gpu_lost_marker_path() -> Option { + gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker")) +} + +/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a +/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it +/// never failed. +pub(crate) fn gpu_lost_reason() -> Option { + lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string)) +} + +/// Record that LightCraft stopped after the GPU was lost, for the next launch. +pub(crate) fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) { + let Some(m) = marker else { return }; + if let Some(d) = m.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Err(e) = std::fs::write(m, format!("{reason}\n")) { + log::warn!("could not write {}: {e}", m.display()); + } +} + +/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a +/// `--memory` session, which leaves the disk as it found it). +pub(crate) fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option { + let m = marker?; + let reason = std::fs::read_to_string(m).ok()?; + if !in_memory { + let _ = std::fs::remove_file(m); + } + Some(format!( + "LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \ + is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \ + an up-to-date graphics driver may help.", + reason.trim() + )) +} diff --git a/apps/lightcraft/src/launch.rs b/apps/lightcraft/src/launch.rs new file mode 100644 index 000000000..a7b8904d6 --- /dev/null +++ b/apps/lightcraft/src/launch.rs @@ -0,0 +1,138 @@ +use super::prefs::config_dir; + +pub(crate) const HELP: &str = "\ +lightcraft — photo library + raw developer + +USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) + +OPTIONS: + --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or + a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held, + else ~/Pictures/LightCraft Library) + --no-demo don't seed a new library with the procedural demo photos + --memory throwaway in-memory session (alias --demo); nothing is saved + --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; + see docs/control-protocol.md) + --version, --help + +ENVIRONMENT: + LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder). + Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData) + LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, + Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. + LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; + optional: LightCraft offers to download it when first needed) + LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order) + LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings + from other crates); else RUST_LOG=. Records go to stderr and to + /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log) +"; + +/// The launch arguments' files and folders to import (absolute), and a notice for each one that +/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374). +pub(crate) fn launch_imports(args: &[String]) -> (Vec, Vec) { + let cwd = std::env::current_dir().ok(); + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + launch_imports_in(args, cwd.as_deref(), home.as_deref()) +} + +pub(crate) fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec, Vec) { + use lightcraft_engine::import::{LaunchPath, launch_path}; + let (mut files, mut notices) = (Vec::new(), Vec::new()); + for a in args { + match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) { + Some(LaunchPath::Import(p)) => files.push(p), + Some(LaunchPath::Refused { path, why }) => { + log::warn!("not importing {path} given at launch: {why}"); + notices + .push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…")); + } + None => {} + } + } + (files, notices) +} + +/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). +/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs. +pub(crate) fn log_dir() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("logs")) +} + +/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is +/// the option or variable it came from). Logged before the log file opens, so it is kept for it. +pub(crate) fn control_port_from(what: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{what} {value:?} is not a port number; no control server"); + } + port +} + +/// An isolated profile keeps the emergency crash log with its other app data. With no +/// override (or an empty override), retain the historical system-temp location. +pub(crate) fn panic_log_path_with(env: impl Fn(&str) -> Option, temp: std::path::PathBuf) -> std::path::PathBuf { + env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log") +} + +/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU +/// was lost (`lost`): GPU rendering is off from the next start. +pub(crate) fn stopped_text(error: &str, lost: Option<&str>) -> String { + match lost { + Some(reason) => format!( + "{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \ + Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on" + ), + None => error.to_string(), + } +} + +/// LightCraft stopped while its window was up: say so in the log and in a native message box +/// naming the log file (as [`startup_failed`] does for a start that failed). +pub(crate) fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft stopped: {text}"); + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return; // tests and scripts: no dialog to click away + } + let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref()); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(title) + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +/// The window could not start (e.g. no usable graphics device): say so in the log and in a native +/// message box naming the log file, instead of exiting without a trace — the release builds have +/// no console on Windows, and an app started from Finder shows no standard error (issue #260). +pub(crate) fn startup_failed(error: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft could not open its window: {error}"); + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return; // tests and scripts: no dialog to click away + } + let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref()); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(title) + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +#[cfg(test)] +mod config_path_tests { + use super::*; + + #[test] + fn config_override_keeps_panic_log_in_isolated_profile() { + let temp = std::path::PathBuf::from("temp"); + assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log")); + assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log")); + assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log")); + } +} diff --git a/apps/lightcraft/src/logging.rs b/apps/lightcraft/src/logging.rs index 6239aded6..dd174972d 100644 --- a/apps/lightcraft/src/logging.rs +++ b/apps/lightcraft/src/logging.rs @@ -343,369 +343,4 @@ pub fn record_panics(logger: &'static AppLogger) { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, UNIX_EPOCH}; - - fn temp_dir(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d - } - - fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read log") - } - - fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { - use log::Log; - logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); - } - - /// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and - /// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them. - fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool { - let max = match lightcraft_log { - "debug" => LevelFilter::Debug, - "info" => LevelFilter::Info, - _ => LevelFilter::Warn, - }; - level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft")) - } - - #[test] - fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() { - let f = Filter::parse(DEFAULT_FILTER); - assert_eq!(f.level_for("lightcraft"), LevelFilter::Info); - assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info); - assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info); - assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); - assert_eq!(f.level_for("naga"), LevelFilter::Warn); - assert_eq!(f.max(), LevelFilter::Info); - assert_eq!(filter_spec(None, None), DEFAULT_FILTER); - // An empty variable is the same as an unset one. - assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER); - } - - /// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger. - #[test] - fn lightcraft_log_keeps_its_meaning() { - let targets = - ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; - let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace]; - for value in ["info", "debug", "warn", "error", "verbose", "INFO"] { - let f = Filter::parse(&filter_spec(Some(value), None)); - for target in targets { - for level in levels { - assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}"); - } - } - } - assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug); - assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn); - } - - #[test] - fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() { - assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info"); - let f = Filter::parse(&filter_spec(None, Some("debug"))); - assert_eq!(f.level_for("eframe"), LevelFilter::Debug); - // The app's own variable is the more specific choice. - let f = Filter::parse(&filter_spec(Some("info"), Some("trace"))); - assert_eq!(f.level_for("eframe"), LevelFilter::Warn); - assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info); - } - - #[test] - fn directives_follow_env_logger_and_the_most_specific_one_wins() { - let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off"); - assert_eq!(f.level_for("eframe"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); - assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace); - assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off); - assert_eq!(f.max(), LevelFilter::Trace); - // A module name is matched at `::` boundaries, not as a bare prefix. - assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); - // A trailing `*` is a prefix. - assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug); - assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error); - // A bare target name sets that target to the most verbose level, as env_logger does. - assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); - assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); - // Levels are case-insensitive and surrounding blanks are ignored. - assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug); - assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn); - } - - #[test] - fn hostile_specs_never_panic_and_fall_back_sensibly() { - let long = "x".repeat(100_000); - let many = "a=info,".repeat(10_000); - for spec in [ - "", - ",,,", - "=", - "==", - "=debug", - "lightcraft=", - "nonsense=loud", - "🦀=info", - "*", - "*=", - "=*", - "a=b=c", - "warn,,lightcraft=DEBUG", - "\0", - "é::ü=trace", - &long, - &many, - ] { - let f = Filter::parse(spec); - for target in ["", "lightcraft", "🦀", "a::b", "*"] { - let _ = f.level_for(target); - } - let _ = f.max(); - let _ = filter_spec(Some(spec), Some(spec)); - } - assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug); - // An unknown level is skipped: that target keeps the default. - assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); - assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error); - assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); - assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); - } - - #[test] - fn timestamps_are_utc_with_milliseconds() { - assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); - // Leap day, and the day after it. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); - // 2000 is a leap year (divisible by 400), 2100 is not. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. - assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); - // Past chrono's year range still converts via the 400-year cycle (the old code did too, - // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' - // SystemTime can't hold this instant, so it is only checked where it exists. - // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { - assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); - } - // Years past 9999 keep the plain zero-padded year (no `+`). - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); - // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. - // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { - assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); - } - } - - #[test] - fn a_line_carries_time_level_thread_target_and_message() { - assert_eq!( - format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"), - "2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n" - ); - // A multi-line message keeps its lines; the record still ends in one newline. - assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); - } - - #[test] - fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { - let dir = temp_dir("rotate"); - for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); - assert_eq!(path, dir.join(LOG_FILE)); - assert_eq!(read(&path), ""); - std::fs::write(&path, format!("run {run}")).expect("write"); - } - assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); - assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3"); - assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2"); - assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { - let dir = temp_dir("blocked"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "a file where the directory should be").expect("block"); - assert!(rotate(&dir).is_err()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { - let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early 1\n"); - sink.write("early 2\n"); - sink.attach(file); - sink.write("late\n"); - assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { - let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING + 5 { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); - assert!(text.contains("5 earlier log lines were dropped"), "{text}"); - assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn exactly_a_full_pending_buffer_drops_nothing() { - let dir = temp_dir("full"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().count(), MAX_PENDING); - assert!(!text.contains("dropped"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn without_a_file_nothing_is_kept_in_memory() { - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early\n"); - sink.no_file(); - sink.write("late\n"); - assert!(sink.pending.is_empty() && sink.dropped == 0); - } - - #[test] - fn the_file_stops_at_its_size_cap_with_one_note() { - let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(100); - sink.attach(file); - for i in 0..50 { - sink.write(&format!("line {i:02} with some padding\n")); - } - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); - assert_eq!(text.matches("log file reached").count(), 1, "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_logger_filters_records_and_writes_them_to_the_attached_file() { - use log::Log; - let dir = temp_dir("logger"); - let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); - record(&logger, log::Level::Info, "lightcraft", "before the settings directory"); - record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); - let path = logger.attach_dir(&dir).expect("attach"); - assert_eq!(path, dir.join(LOG_FILE)); - record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); - record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom"); - record(&logger, log::Level::Debug, "lightcraft", "below info"); - let text = read(&path); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 3, "{text}"); - assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}"); - assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); - assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}"); - assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build())); - assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); - // Attaching again rotates: the first log becomes `.1`. - logger.attach_dir(&dir).expect("attach again"); - assert_eq!(read(&dir.join("lightcraft.1.log")), text); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn records_from_many_threads_are_whole_lines() { - let dir = temp_dir("threads"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let path = logger.attach_dir(&dir).expect("attach"); - let workers: Vec<_> = (0..8) - .map(|t| { - let l = std::sync::Arc::clone(&logger); - std::thread::spawn(move || { - for i in 0..100 { - record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}")); - } - }) - }) - .collect(); - for w in workers { - w.join().expect("worker"); - } - let text = read(&path); - assert_eq!(text.lines().count(), 800); - assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { - let dir = temp_dir("unusable"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "not a directory").expect("block"); - let logger = AppLogger::new(Filter::parse("info"), false); - record(&logger, log::Level::Info, "lightcraft", "early"); - assert!(logger.attach_dir(&dir).is_err()); - record(&logger, log::Level::Error, "x", "still fine"); - // Nothing waits in memory for a file that won't come. - assert!(logger.sink.lock().expect("sink").pending.is_empty()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn a_poisoned_sink_still_logs() { - let dir = temp_dir("poison"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let l = std::sync::Arc::clone(&logger); - let _ = std::thread::spawn(move || { - let _guard = l.sink.lock().expect("lock"); - panic!("poison the sink"); - }) - .join(); - assert!(logger.sink.is_poisoned()); - let path = logger.attach_dir(&dir).expect("attach"); - record(&logger, log::Level::Warn, "lightcraft", "after the poison"); - assert!(read(&path).contains("lightcraft: after the poison")); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The panic hook's report lands in the file, and never deadlocks when the panic happened - /// while this thread held the sink. - #[test] - fn a_panic_report_goes_to_the_file_and_never_deadlocks() { - let dir = temp_dir("panic"); - let logger = AppLogger::new(Filter::parse("off"), false); - let path = logger.attach_dir(&dir).expect("attach"); - logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom"); - { - let _held = logger.sink.lock().expect("lock"); - logger.record_panic("main", "while the sink is held"); - } - let text = read(&path); - assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}"); - assert!(!text.contains("while the sink is held"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/apps/lightcraft/src/logging/tests.rs b/apps/lightcraft/src/logging/tests.rs new file mode 100644 index 000000000..b801c7d75 --- /dev/null +++ b/apps/lightcraft/src/logging/tests.rs @@ -0,0 +1,363 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") +} + +fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); +} + +/// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and +/// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them. +fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool { + let max = match lightcraft_log { + "debug" => LevelFilter::Debug, + "info" => LevelFilter::Info, + _ => LevelFilter::Warn, + }; + level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft")) +} + +#[test] +fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("lightcraft"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + assert_eq!(filter_spec(None, None), DEFAULT_FILTER); + // An empty variable is the same as an unset one. + assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER); +} + +/// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger. +#[test] +fn lightcraft_log_keeps_its_meaning() { + let targets = ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; + let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace]; + for value in ["info", "debug", "warn", "error", "verbose", "INFO"] { + let f = Filter::parse(&filter_spec(Some(value), None)); + for target in targets { + for level in levels { + assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}"); + } + } + } + assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug); + assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn); +} + +#[test] +fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() { + assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info"); + let f = Filter::parse(&filter_spec(None, Some("debug"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Debug); + // The app's own variable is the more specific choice. + let f = Filter::parse(&filter_spec(Some("info"), Some("trace"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Warn); + assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info); +} + +#[test] +fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace); + assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug); + assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn); +} + +#[test] +fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "lightcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,lightcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "lightcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + let _ = filter_spec(Some(spec), Some(spec)); + } + assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); +} + +#[test] +fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } +} + +#[test] +fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"), + "2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); +} + +#[test] +fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2"); + assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); +} + +#[test] +fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "lightcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom"); + record(&logger, log::Level::Debug, "lightcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("lightcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "lightcraft", "early"); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "lightcraft", "after the poison"); + assert!(read(&path).contains("lightcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The panic hook's report lands in the file, and never deadlocks when the panic happened +/// while this thread held the sink. +#[test] +fn a_panic_report_goes_to_the_file_and_never_deadlocks() { + let dir = temp_dir("panic"); + let logger = AppLogger::new(Filter::parse("off"), false); + let path = logger.attach_dir(&dir).expect("attach"); + logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom"); + { + let _held = logger.sink.lock().expect("lock"); + logger.record_panic("main", "while the sink is held"); + } + let text = read(&path); + assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}"); + assert!(!text.contains("while the sink is held"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7d30e69cd..7b572fa0e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -44,6 +44,25 @@ use lightcraft_engine::Session; use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; +mod gpu_crash; +mod launch; +mod platform; +mod prefs; +mod session; + +use gpu_crash::{gpu_crash_check, gpu_lost_check_at, gpu_lost_marker_path, gpu_lost_reason, record_gpu_lost, window_files}; +#[cfg(test)] +use gpu_crash::{gpu_crash_check_at, gpu_crash_notice, window_wgpu_options}; +#[cfg(test)] +use launch::launch_imports_in; +use launch::{HELP, control_port_from, launch_imports, log_dir, panic_log_path_with, startup_failed, stopped_text, stopped_while_running}; +#[cfg(test)] +use platform::{explorer_select_arg, explorer_select_command, windows_open_url_command}; +use prefs::{PrefsWriter, as_remembered, load_prefs, prefs_path}; +#[cfg(test)] +use prefs::{load_prefs_at, write_atomic}; +use session::{open_session, services}; + /// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name. const APP_ID: &str = "ai.storyteller.lightcraft"; @@ -75,8 +94,10 @@ impl eframe::App for App { self.0.logic(ctx); self.1.tick(&mut self.0, ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + /// Forwards the host's raw-input pass to the app's hook before egui processes it, keeping + /// modal/prompt input barriers and queued synthetic or dropped input ahead of the panels. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0); @@ -112,743 +133,12 @@ impl eframe::App for App { /// a second; anything slower gives way to quitting. const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); -/// 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: -/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12 -/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471). -/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`. -#[cfg(test)] -fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { - window_gpu::base_options() -} - -/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions -/// and `LIGHTCRAFT_NO_PREFS`. -fn window_files(in_memory: bool) -> window_gpu::Files { - window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none())) -} - -/// Written while the GPU compute device is created, removed once that returned -/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). -fn gpu_marker_path() -> Option { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return None; - } - config_dir().map(|d| d.join("gpu-init.marker")) -} - -/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU -/// driver while creating the device), the notice to show — GPU rendering then starts off. -/// -/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would -/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU -/// starts off there too, and the next ordinary launch reports and clears it. -fn gpu_crash_check(in_memory: bool) -> Option { - gpu_crash_check_at(gpu_marker_path(), in_memory) -} - -fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { - use lightcraft_engine::gpu::backend; - let left = if in_memory { - marker.as_deref().and_then(backend::read_init_marker) - } else { - let left = marker.as_deref().and_then(backend::take_init_marker); - backend::set_init_marker(marker); - left - }; - left.map(|what| gpu_crash_notice(&what)) -} - -fn gpu_crash_notice(what: &str) -> String { - format!( - "LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \ - To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \ - LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)." - ) -} - -/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next -/// launch renders on the CPU and says why. -fn gpu_lost_marker_path() -> Option { - gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker")) -} - -/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a -/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it -/// never failed. -fn gpu_lost_reason() -> Option { - lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string)) -} - -/// Record that LightCraft stopped after the GPU was lost, for the next launch. -fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) { - let Some(m) = marker else { return }; - if let Some(d) = m.parent() { - let _ = std::fs::create_dir_all(d); - } - if let Err(e) = std::fs::write(m, format!("{reason}\n")) { - log::warn!("could not write {}: {e}", m.display()); - } -} - -/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a -/// `--memory` session, which leaves the disk as it found it). -fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option { - let m = marker?; - let reason = std::fs::read_to_string(m).ok()?; - if !in_memory { - let _ = std::fs::remove_file(m); - } - Some(format!( - "LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \ - is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \ - an up-to-date graphics driver may help.", - reason.trim() - )) -} - -/// 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. -fn display_profile_dir() -> Option { - let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); - let user = |rel: &str| home.as_ref().map(|h| h.join(rel)); - let candidates: Vec> = if cfg!(target_os = "macos") { - vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())] - } else if cfg!(windows) { - let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); - vec![Some(root.join("System32").join("spool").join("drivers").join("color"))] - } else { - let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share")); - vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())] - }; - candidates.into_iter().flatten().find(|d| d.is_dir()) -} - -fn config_dir() -> Option { - lightcraft_engine::config::config_dir() -} - -/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size -/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it -/// opens is the system's to say. -fn as_remembered(builder: egui::ViewportBuilder, window: Option) -> egui::ViewportBuilder { - let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size)); - // (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen - // to a size of its own choosing) - if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder } -} - -/// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or -/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. -fn prefs_path() -> Option { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return None; - } - config_dir().map(|d| d.join("ui.json")) -} - -/// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user -/// when the file exists but can't be used (issue #103): a damaged file is kept as -/// `ui.json.corrupt-` first, so the next save can't lose the library location in it; -/// one that can't be read at all is not written this session (`keep_file`). -/// -/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing -/// (issue #164), so a damaged file is left as it is there, not set aside. -fn load_prefs(in_memory: bool) -> (Option, Option, bool) { - let Some(path) = prefs_path() else { return (None, None, false) }; - load_prefs_at(&path, !in_memory) -} - -/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. -fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { - let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; - let fields = saved.as_object_mut().ok_or("settings must be an object")?; - let recovered = - fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { - Ok(Some(workspace)) => workspace.validate().is_err(), - Ok(None) => false, - Err(_) => true, - }); - if recovered { - fields.remove("docking"); - } - let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?; - Ok((ui, recovered)) -} - -fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) { - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false), - Err(e) => { - let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display()); - return (None, Some(msg), true); - } - }; - match decode_saved_ui(&bytes) { - Ok((ui, false)) => (Some(ui.sanitized()), None, false), - Ok((ui, true)) => { - let msg = format!( - "The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.", - path.display() - ); - (Some(ui.sanitized()), Some(msg), true) - } - Err(e) if !set_aside_damaged => { - let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display()); - (None, Some(msg), true) - } - Err(e) => { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let keep = path.with_file_name(format!("ui.json.corrupt-{secs}")); - let kept = std::fs::rename(path, &keep); - let what = match &kept { - Ok(()) => format!("it was kept as {}", keep.display()), - Err(r) => format!("it couldn't be set aside ({r})"), - }; - let msg = format!( - "The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.", - path.display() - ); - (None, Some(msg), kept.is_err()) - } - } -} - -/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full -/// disk leaves the old file or the new one, never a truncated one). -fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let tmp = path.with_extension("json.tmp"); - let written = (|| { - let mut f = std::fs::File::create(&tmp)?; - f.write_all(bytes)?; - f.sync_all() - })(); - if let Err(e) = written { - let _ = std::fs::remove_file(&tmp); - return Err(e); - } - std::fs::rename(&tmp, path)?; - if let Some(dir) = path.parent() - && let Ok(d) = std::fs::File::open(dir) - { - let _ = d.sync_all(); - } - Ok(()) -} - -/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library -/// changes, every few seconds when anything else changed, and at exit. -/// -/// Nothing is written while the session is one that promises to save nothing: `--memory` -/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the -/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the -/// library it opened is saved then). -#[derive(Default)] -struct PrefsWriter { - /// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder). - path: Option, - written: Vec, - library: String, - checked: f64, - /// The last write failed (reported once until a write works). - failing: bool, - /// `ui.json` couldn't be read at launch: never overwrite it this session. - keep_file: bool, - /// Follows the window, to remember the size it rests at (`UiState::window`). - window: lightcraft_ui_egui::window_place::WindowWatch, - /// When the window was first looked at: its watch goes by the clock from then on, not by - /// the interface's time, which stands still while the window is hidden. - window_since: Option, -} - -impl PrefsWriter { - fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { - self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing)) - } - - /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be - /// opened) saves nothing. - fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> { - if self.keep_file || temporary { - return Ok(()); - } - let Some(path) = self.path.clone() else { return Ok(()) }; - if let Some(workspace) = &ui.docking { - workspace.validate()?; - } - let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; - if bytes == self.written { - return Ok(()); - } - path.parent() - .map_or(Ok(()), std::fs::create_dir_all) - .and_then(|()| write_atomic(&path, &bytes)) - .map_err(|e| format!("saving the app settings failed: {e}"))?; - self.written = bytes; - self.library = ui.settings.library_path.clone(); - Ok(()) - } - - /// What to remember of the window as the app quits ([`WindowWatch::at_exit`]). - /// - /// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit - fn window_at_exit( - &mut self, - remembered: Option, - ) -> Option { - match self.window_since { - Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()), - None => remembered, - } - } - - fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { - // (asked for before the input is read: the context is not asked from inside its own lock) - let zoom = ctx.zoom_factor(); - let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom))); - // how the window is, to open like that next time (written with the settings below). What - // the settings hold is checked first: a script may have set it (`ui.set`) - let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); - let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64(); - app.ui.window = self.window.note(remembered, &seen, at); - if let Some(wait) = self.window.waits(at) { - // (a size is only remembered once the window has rested at it: look again then, though - // nothing else asks for a frame) - ctx.request_repaint_after(wait); - } - let moved = app.ui.settings.library_path != self.library; - if !moved && now - self.checked < 3.0 { - return; - } - self.checked = now; - match self.save(app) { - Ok(()) => self.failing = false, - Err(e) => { - if !self.failing { - log::warn!("{e}"); - app.notices.push(format!("{e}. LightCraft keeps trying.")); - } - self.failing = true; - // don't retry every frame - self.library = app.ui.settings.library_path.clone(); - } - } - } -} - -/// The Windows command that opens `url` in the default browser. `explorer ` opened File -/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link -/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so -/// `?`, `&` and `#` need no escaping. -fn windows_open_url_command(url: &str) -> std::process::Command { - let mut c = std::process::Command::new("rundll32"); - c.args(["url.dll,FileProtocolHandler", url]); - c -} - -/// Explorer's `/select,` switch (issue #272). Explorer parses its own command line and only -/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`), -/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that -/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain -/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line -/// always holds exactly this one argument. -fn explorer_select_arg(path: &str) -> String { - let path = path.replace('/', "\\").replace('"', ""); - let path = match path.strip_prefix(r"\\?\UNC\") { - Some(unc) => format!(r"\\{unc}"), - None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(), - }; - format!("/select,\"{path}\"") -} - -/// `explorer /select,""`. Rust's own argument quoting wraps the whole switch in quotes -/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't -/// recognise, so on Windows the switch goes on the command line exactly as built. -fn explorer_select_command(path: &str) -> std::process::Command { - let mut c = std::process::Command::new("explorer"); - let arg = explorer_select_arg(path); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - c.raw_arg(arg); - } - #[cfg(not(windows))] - { - c.arg(arg); - } - c -} - -/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is -/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray -/// top-level ("Failed to associate portal window with parent window" in the portal's log): a -/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so -/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself; -/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including -/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog -/// becomes a sheet and on Windows a modal, a behaviour change this does not make. -static DIALOG_PARENT: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// A file dialog owned by the app's window, or a plain one before the window exists. -fn file_dialog() -> rfd::FileDialog { - DIALOG_PARENT.get().cloned().unwrap_or_default() -} - -/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints -/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder. -fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services { - let ctx = cc.egui_ctx.clone(); - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc)); - Services { - // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread - // stopped the window answering the compositor, which then reported the app as hung. - picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { - use lightcraft_ui_egui::pick::PickKind; - let (tx, rx) = std::sync::mpsc::channel(); - let repaint = ctx.clone(); - std::thread::Builder::new() - .name("file-dialog".into()) - .spawn(move || { - let mut d = file_dialog(); - if let Some(t) = &req.title { - d = d.set_title(t); - } - if let Some((name, exts)) = &req.filter { - d = d.add_filter_nocase(name.clone(), exts); - } - if let Some(n) = &req.file_name { - d = d.set_file_name(n); - } - let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); - let paths: Vec = match req.kind { - PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(), - PickKind::File => d.pick_file().map(s).into_iter().collect(), - PickKind::Folder => d.pick_folder().map(s).into_iter().collect(), - PickKind::Save => d.save_file().map(s).into_iter().collect(), - }; - // the app may have quit meanwhile: nobody to tell - let _ = tx.send(paths); - repaint.request_repaint(); - }) - .map_err(|e| format!("could not start the file dialog: {e}"))?; - Ok(rx) - })), - log_file: log_file.map(|p| p.display().to_string()), - pick_lightroom_catalog: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_folder: Some(Box::new(|| { - file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) - })), - #[cfg(target_os = "linux")] - open_model_folder: Some(std::sync::Arc::new(|path: &str| { - std::process::Command::new("xdg-open") - .arg(path) - .status() - .map_err(|e| e.to_string()) - .and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) }) - })), - open_with: Some(Box::new(editors::open_with)), - open_all_with: Some(Box::new(editors::open_all_with)), - check_editor: Some(Box::new(editors::check)), - open_url: Some(Box::new(|url: &str| { - if !url.starts_with("https://") { - return Err("only https links are opened".into()); - } - let status = if cfg!(target_os = "macos") { - std::process::Command::new("open").arg(url).status() - } else if cfg!(target_os = "windows") { - windows_open_url_command(url).status() - } else { - std::process::Command::new("xdg-open").arg(url).status() - }; - status.map(|_| ()).map_err(|e| e.to_string()) - })), - reveal: Some(Box::new(|path: &str| { - let status = if cfg!(target_os = "macos") { - std::process::Command::new("open").args(["-R", path]).status() - } else if cfg!(target_os = "windows") { - explorer_select_command(path).status() - } else { - let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into()); - std::process::Command::new("xdg-open").arg(dir).status() - }; - status - .map_err(|e| e.to_string()) - .and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) }) - })), - pick_files: Some(Box::new(|| { - file_dialog() - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - pick_denoise_model: Some(Box::new(|| { - file_dialog() - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().into_owned()]) - .unwrap_or_default() - })), - pick_preset_files: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) - .add_filter_nocase( - lightcraft_ui_egui::i18n::tr("Presets & Profiles"), - &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"], - ) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - pick_model_file: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().to_string()]) - .unwrap_or_default() - })), - pick_tracklog: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().to_string()]) - .unwrap_or_default() - })), - // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) - #[cfg(target_os = "macos")] - screen_profile: Some(Box::new(screen_profile::icc)), - // Linux: the profile the desktop assigns in colord, read off the UI thread - #[cfg(target_os = "linux")] - screen_profile: Some(colord::service()), - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - screen_profile: None, - pick_display_profile: Some(Box::new(|| { - let mut d = file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]); - if let Some(dir) = display_profile_dir() { - d = d.set_directory(dir); - } - d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default() - })), - save_preset_file: Some(Box::new(|name: &str| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_keyword_list: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Keywords")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().to_string()]) - .unwrap_or_default() - })), - save_keyword_list: Some(Box::new(|name: &str| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Export Keywords")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_curve_preset_files: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - save_curve_preset_file: Some(Box::new(|name: &str| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_slideshow_templates: Some(Box::new(|| { - rfd::FileDialog::new() - .set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - save_slideshow_template: Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - // atomic (temp file + sync + rename): a failed write never leaves a truncated file - write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), - write: Some(Box::new(lightcraft_engine::export::write_file)), - png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() - })), - // the library is a folder on disk: backed up with the user's other files - backup_library: None, - restore_library: None, - system_theme: appearance::service(), - } -} - -/// The library session with the folders face models (`/models`) and slideshow templates -/// (`/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet: -/// the window lists the recent ones. -fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { - let (mut s, problem) = match launch { - Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), - Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), - }; - s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); - s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); - // Control requests are answered on the UI thread: Add to Apple Photos must not wait there - s.apple_photos_background = true; - (s, problem) -} - -/// The persistent library session, or with `--memory` an in-memory one (demo photos). -/// -/// If the library can't be opened the session is empty and in memory — never seeded with the -/// demo photos, never written anywhere — and the problem is returned: the window then says so -/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100). -fn open_library_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { - if in_memory { - return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None); - } - let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock(); - let Some(dir) = dir else { - log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)"); - return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); - }; - let t0 = std::time::Instant::now(); - let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock(); - match s.open_library(&dir, seed_demo) { - Ok(r) => { - let (replayed, torn) = (r.replayed, r.torn_bytes); - log::info!( - "library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", - dir.display(), - s.catalog.len(), - if torn > 0 { ", torn tail repaired" } else { "" }, - t0.elapsed().as_secs_f64() * 1000.0 - ); - (s, None) - } - Err(e) => { - log::error!("can't open library {}: {e}", dir.display()); - (unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string()))) - } - } -} - -const HELP: &str = "\ -lightcraft — photo library + raw developer - -USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) - -OPTIONS: - --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or - a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held, - else ~/Pictures/LightCraft Library) - --no-demo don't seed a new library with the procedural demo photos - --memory throwaway in-memory session (alias --demo); nothing is saved - --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; - see docs/control-protocol.md) - --version, --help - -ENVIRONMENT: - LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder). - Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData) - LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, - Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. - LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; - optional: LightCraft offers to download it when first needed) - LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order) - LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings - from other crates); else RUST_LOG=. Records go to stderr and to - /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log) -"; - -/// The launch arguments' files and folders to import (absolute), and a notice for each one that -/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374). -fn launch_imports(args: &[String]) -> (Vec, Vec) { - let cwd = std::env::current_dir().ok(); - let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); - launch_imports_in(args, cwd.as_deref(), home.as_deref()) -} - -fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec, Vec) { - use lightcraft_engine::import::{LaunchPath, launch_path}; - let (mut files, mut notices) = (Vec::new(), Vec::new()); - for a in args { - match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) { - Some(LaunchPath::Import(p)) => files.push(p), - Some(LaunchPath::Refused { path, why }) => { - log::warn!("not importing {path} given at launch: {why}"); - notices - .push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…")); - } - None => {} - } - } - (files, notices) -} - -/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). -/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs. -fn log_dir() -> Option { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return None; - } - config_dir().map(|d| d.join("logs")) -} - -/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is -/// the option or variable it came from). Logged before the log file opens, so it is kept for it. -fn control_port_from(what: &str, value: Option) -> Option { - let value = value?; - let port = value.trim().parse().ok(); - if port.is_none() { - log::warn!("{what} {value:?} is not a port number; no control server"); - } - port -} - -/// An isolated profile keeps the emergency crash log with its other app data. With no -/// override (or an empty override), retain the historical system-temp location. -fn panic_log_path_with(env: impl Fn(&str) -> Option, temp: std::path::PathBuf) -> std::path::PathBuf { - env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log") -} - fn main() -> eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::LIGHTCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so every start-up record is kept for the log file (`logging`). let logger = logging::install(); lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir())); @@ -977,6 +267,7 @@ fn main() -> eframe::Result { // HDR display: SDR unless the surface, platform and display all allow HDR hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format)); let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref())); + app.set_startup_update_check(true); if let Some(ui) = prefs { app.ui = ui; } @@ -1079,530 +370,6 @@ fn main() -> eframe::Result { started } -/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU -/// was lost (`lost`): GPU rendering is off from the next start. -fn stopped_text(error: &str, lost: Option<&str>) -> String { - match lost { - Some(reason) => format!( - "{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \ - Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on" - ), - None => error.to_string(), - } -} - -/// LightCraft stopped while its window was up: say so in the log and in a native message box -/// naming the log file (as [`startup_failed`] does for a start that failed). -fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) { - log::error!("LightCraft stopped: {text}"); - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return; // tests and scripts: no dialog to click away - } - let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref()); - let _ = rfd::MessageDialog::new() - .set_level(rfd::MessageLevel::Error) - .set_title(title) - .set_description(text) - .set_buttons(rfd::MessageButtons::Ok) - .show(); -} - -/// The window could not start (e.g. no usable graphics device): say so in the log and in a native -/// message box naming the log file, instead of exiting without a trace — the release builds have -/// no console on Windows, and an app started from Finder shows no standard error (issue #260). -fn startup_failed(error: &str, log_file: Option<&std::path::Path>) { - log::error!("LightCraft could not open its window: {error}"); - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return; // tests and scripts: no dialog to click away - } - let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref()); - let _ = rfd::MessageDialog::new() - .set_level(rfd::MessageLevel::Error) - .set_title(title) - .set_description(text) - .set_buttons(rfd::MessageButtons::Ok) - .show(); -} - #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - fn dir(tag: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// Issue #374: launch arguments that would walk a whole drive or home folder are reported, - /// not imported; empty ones are ignored; the rest are imported as absolute paths. - #[test] - fn launch_arguments_never_walk_a_drive_or_the_home_folder() { - let home = dir("launch"); - std::fs::create_dir_all(home.join("Trip")).unwrap(); - let args: Vec = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect(); - let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home)); - assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]); - assert_eq!(notices.len(), 2, "{notices:?}"); - assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}"); - // the working folder is a drive root (a launcher's choice): `.` is never the whole drive - let root = home.ancestors().last().unwrap().to_path_buf(); - let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None); - assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}"); - let _ = std::fs::remove_dir_all(&home); - } - - /// Issue #236: the window answers control requests on its UI thread, so the desktop session - /// adds to Apple Photos on a worker (headless sessions wait instead). - #[test] - fn the_desktop_session_adds_to_apple_photos_in_the_background() { - let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); - assert!(problem.is_none()); - assert!(s.apple_photos_background); - assert!(!lightcraft_engine::Session::new().apple_photos_background); - } - - /// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo - /// photos) before the user chooses, and the settings are still saved while the list shows (a - /// removed entry is kept), unlike the failed-library window's temporary session. - #[test] - fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { - let (s, problem) = open_session(false, &Launch::Ask, true); - assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty()); - assert!(s.apple_photos_background, "the same desktop session as an opened one"); - - let d = dir("writer-ask"); - let path = d.join("ui.json"); - let mut app = LightcraftApp::new(s, Services::default()); - app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; - app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); - let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - app.ui.settings.recent_libraries.remove(0); - w.save(&app).unwrap(); - let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); - // the failed-library window still writes nothing - app.library_problem = Some(LibraryProblem::new("/gone/b", "not there")); - app.ui.settings.recent_libraries.clear(); - w.save(&app).unwrap(); - let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #103: a damaged ui.json (which holds the library location) is kept aside and - /// reported, not silently replaced by the defaults. - #[test] - fn damaged_ui_json_is_kept_and_reported() { - let d = dir("damaged"); - let path = d.join("ui.json"); - std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap(); - let (ui, warning, keep) = load_prefs_at(&path, true); - assert!(ui.is_none() && !keep); - let warning = warning.unwrap(); - assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}"); - assert!(!path.exists()); - let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); - assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}"); - // a missing file is just "no settings yet" - assert_eq!(load_prefs_at(&path, true).1, None); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is - /// reported and left where it is, not set aside. - #[test] - fn memory_session_leaves_a_damaged_ui_json_alone() { - let d = dir("damaged-memory"); - let path = d.join("ui.json"); - let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#; - std::fs::write(&path, bytes).unwrap(); - let (ui, warning, keep) = load_prefs_at(&path, false); - assert!(ui.is_none() && keep); - let warning = warning.unwrap(); - assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}"); - assert_eq!(std::fs::read(&path).unwrap(), bytes); - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn docking_survives_native_settings_file_reload() { - use lightcraft_ui_egui::docking::Panel; - let d = dir("docking-reload"); - let path = d.join("ui.json"); - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap(); - app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap(); - let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - writer.save_ui(&app.ui, false).unwrap(); - let (restored, warning, keep) = load_prefs_at(&path, false); - assert!(warning.is_none()); - assert!(!keep); - let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - restored_app.ui = restored.unwrap(); - restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap(); - assert_eq!( - restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, - [70.0, 80.0, 310.0, 500.0] - ); - std::fs::remove_dir_all(d).unwrap(); - } - - #[test] - fn future_docking_preserves_library_settings_and_the_original_preferences_file() { - let d = dir("future-docking"); - let path = d.join("ui.json"); - let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() }; - ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into(); - for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] { - let mut saved = serde_json::to_value(&ui).unwrap(); - saved["docking"] = docking; - assert!(serde_json::from_value::(saved.clone()).is_err(), "runtime state parsing stays strict"); - let bytes = serde_json::to_vec(&saved).unwrap(); - for set_aside in [false, true] { - std::fs::write(&path, &bytes).unwrap(); - let (loaded, warning, keep) = load_prefs_at(&path, set_aside); - let loaded = loaded.unwrap(); - assert_eq!(loaded.settings.library_path, ui.settings.library_path); - assert_eq!(loaded.thumb_size, ui.thumb_size); - assert!(!loaded.filmstrip); - assert!(loaded.docking.is_none()); - assert!(warning.unwrap().contains("Other settings were loaded")); - assert!(keep); - let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() }; - writer.save_ui(&loaded, false).unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), bytes); - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file"); - } - } - std::fs::remove_dir_all(d).unwrap(); - } - - /// Issue #164: the writer saves changed settings to its file — and nothing at all without one - /// (`--memory`) or while the session is the temporary one offered when the library can't open. - #[test] - fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() { - let d = dir("writer"); - let path = d.join("sub").join("ui.json"); - let mut ui = UiState::default(); - // an ordinary session: a change is written, the folder created on the way - let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - ui.thumb_size += 50.0; - w.save_ui(&ui, false).unwrap(); - let saved = std::fs::read(&path).unwrap(); - assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved)); - // the temporary session (library problem): a further change is not written… - ui.thumb_size += 50.0; - w.save_ui(&ui, true).unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), saved); - // …until a library opens - w.save_ui(&ui, false).unwrap(); - assert_ne!(std::fs::read(&path).unwrap(), saved); - // --memory: no path, so no file and no folder, whatever changes - let d2 = dir("writer-memory"); - let _ = std::fs::remove_dir_all(&d2); - let mut w = PrefsWriter { path: None, ..Default::default() }; - ui.thumb_size += 50.0; - w.save_ui(&ui, false).unwrap(); - assert!(w.written.is_empty() && !d2.exists()); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create - /// the settings folder) nor removes a marker a crashed launch left; it still reports it, and - /// the next ordinary launch finds it, reports it and clears it. - #[test] - fn memory_session_leaves_the_gpu_marker_and_folder_alone() { - let d = dir("gpu-marker-memory"); - let _ = std::fs::remove_dir_all(&d); - let m = d.join("gpu-init.marker"); - // nothing left behind: no notice, and the folder is not created - assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None); - assert!(!d.exists(), "a --memory session must not create the settings folder"); - // a marker left by a crashed launch: reported, kept - std::fs::create_dir_all(&d).unwrap(); - std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap(); - let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap(); - assert!(notice.contains("GPU rendering is now off"), "{notice}"); - assert!(m.exists(), "the marker stays for the next ordinary launch"); - // the next ordinary launch reports it once and removes it - assert!(gpu_crash_check_at(Some(m.clone()), false).is_some()); - lightcraft_engine::gpu::backend::set_init_marker(None); - assert!(!m.exists()); - assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None); - lightcraft_engine::gpu::backend::set_init_marker(None); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an - /// export, and the dialog said the window "could not open". The stop is recorded; the next - /// launch says why once and renders on the CPU (a `--memory` session leaves the record). - #[test] - fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() { - let d = dir("gpu-lost"); - let m = d.join("gpu-lost.marker"); - assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded"); - assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder"); - record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost"); - let notice = gpu_lost_check_at(Some(&m), true).unwrap(); - assert!( - notice.contains("(device lost (Unknown): Device is lost)") - && notice.contains("GPU rendering is now off") - && notice.contains("Use the GPU for rendering"), - "{notice}" - ); - assert!(m.exists(), "a --memory session leaves it"); - assert!(gpu_lost_check_at(Some(&m), false).is_some()); - assert!(!m.exists()); - assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once"); - // the message box: LightCraft stopped (not "could not open its window"), and why - let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost")); - assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}"); - assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing"); - let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log")); - assert_eq!(title, "LightCraft stopped"); - assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}"); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. - #[test] - fn gpu_crash_marker_is_reported_once() { - let d = dir("gpu-marker"); - let m = d.join("gpu-init.marker"); - assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None); - std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap(); - let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap(); - let notice = gpu_crash_notice(&what); - assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}"); - assert!(!m.exists()); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default. - #[test] - fn window_backends_follow_the_platform_default() { - let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; - let b = n.instance_descriptor.backends; - if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() { - if cfg!(windows) { - assert_eq!(b, eframe::wgpu::Backends::DX12); - } else if cfg!(target_os = "macos") { - assert_eq!(b, eframe::wgpu::Backends::METAL); - } - } - assert!(!b.is_empty()); - } - - /// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any - /// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening. - #[test] - fn window_compiles_dx12_shaders_with_fxc() { - let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; - if std::env::var_os("WGPU_DX12_COMPILER").is_none() { - assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc)); - } - } - - /// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline, - /// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened). - #[test] - fn window_has_no_indirect_call_validation_pipeline() { - let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); - } - } - - /// Issue #234: Windows opens links with the URL protocol handler (the default browser), not - /// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument. - #[test] - fn windows_links_open_in_the_browser() { - let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200"; - let c = windows_open_url_command(url); - assert_eq!(c.get_program(), "rundll32"); - let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); - assert_eq!(args, ["url.dll,FileProtocolHandler", url]); - } - - /// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and - /// without the verbatim prefix, even when the path has spaces (else it opens Documents). - #[test] - fn explorer_select_quotes_the_path() { - assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#); - assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#); - assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#); - assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#); - assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#); - // a `"` can't end the quoted path and add arguments to the raw command line - assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#); - let c = explorer_select_command(r"C:\My Photos\a.jpg"); - assert_eq!(c.get_program(), "explorer"); - let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); - assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]); - } - - #[test] - fn a_control_port_that_is_not_a_number_is_ignored() { - assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980)); - assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001)); - assert_eq!(control_port_from("--control", Some("nope".into())), None); - assert_eq!(control_port_from("--control", Some(String::new())), None); - assert_eq!(control_port_from("--control", Some("70000".into())), None); - assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None); - } - - #[test] - fn ui_json_is_written_atomically() { - let d = dir("atomic"); - let path = d.join("ui.json"); - std::fs::write(&path, b"old").unwrap(); - write_atomic(&path, b"{\"new\": true}").unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); - assert!(!d.join("ui.json.tmp").exists()); - // a failed write leaves the old file - let missing = d.join("gone").join("ui.json"); - assert!(write_atomic(&missing, b"x").is_err()); - assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); - let ui = UiState::default(); - write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap(); - let (loaded, warning, _) = load_prefs_at(&path, true); - assert!(loaded.is_some() && warning.is_none()); - let _ = std::fs::remove_dir_all(&d); - } - - /// Given what the settings remember of the window, when the app's window is built, then it - /// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it - /// opens at the usual size. - #[test] - fn the_window_opens_as_it_was() { - use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace}; - let fresh = as_remembered(egui::ViewportBuilder::default(), None); - assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None)); - let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true })); - assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true))); - // (not asked to un-zoom: macOS would then resize a window that fills its screen) - let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); - assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None)); - } - - /// Given a window that rests at a size, when the settings are written and read again, then - /// they hold how it was. A window in the file that is not one (a hand edit, another version's) - /// reads as nothing remembered and costs no other setting; and a window resized just before - /// the app quits is remembered at that size. - #[test] - fn the_settings_keep_how_the_window_was() { - use lightcraft_ui_egui::window_place::{Seen, WindowPlace}; - let d = dir("window"); - let path = d.join("ui.json"); - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - app.ui.settings.library_path = "/pictures/library".into(); - let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - writer.save(&app).unwrap(); - assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet"); - let window = WindowPlace { size: [1300.0, 800.0], zoomed: true }; - app.ui.window = Some(window); - writer.save(&app).unwrap(); - let (restored, warning, _) = load_prefs_at(&path, false); - assert_eq!((restored.unwrap().window, warning), (Some(window), None)); - - // a window that is no window: the file is read all the same, with its other settings - let text = std::fs::read_to_string(&path).unwrap(); - for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] { - let start = text.find("\"window\"").unwrap(); - let end = start + text[start..].find('}').unwrap() + 1; - std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap(); - let (restored, warning, keep) = load_prefs_at(&path, true); - let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}")); - assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}"); - } - - // resized, and the app quits a moment later: the new size is what is written - let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }); - writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0); - writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400)); - assert!(writer.window_since.is_some()); - assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false })); - assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen"); - let _ = std::fs::remove_dir_all(&d); - } - - /// Given the app's window at a size, when the settings writer looks at it frame after frame, - /// then it asks to be woken while the size has still to rest, remembers the size once it has, - /// and asks for nothing more. A window in the settings that is not one (a script set it) is - /// put right before it is gone by. - #[test] - fn the_writer_follows_the_window_from_frame_to_frame() { - use lightcraft_ui_egui::window_place::WindowPlace; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - let mut writer = PrefsWriter::default(); - let ctx = egui::Context::default(); - let pass = |look: &mut dyn FnMut(&egui::Context)| { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); - out.textures_delta.clear(); - out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) - }; - // (egui's own first frames ask for another: let them pass) - for _ in 0..3 { - pass(&mut |_| {}); - } - app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true }); - let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); - assert_eq!(app.ui.window, None, "no window, and not yet at rest"); - assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}"); - // a second later - writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1))); - pass(&mut |ctx| writer.tick(&mut app, ctx)); - assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); - let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); - assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}"); - } - - /// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. - /// The check runs in a child (this test binary again), since on macOS it ends the process. - #[test] - fn on_exit_ends_the_process_on_macos() { - if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() { - let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); - let mut app = App( - app, - PrefsWriter::default(), - #[cfg(target_os = "macos")] - None, - ); - println!("calling on_exit"); - eframe::App::on_exit(&mut app, None); - println!("on_exit returned"); - return; - } - let out = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"]) - .env("LIGHTCRAFT_TEST_ON_EXIT", "1") - .env("LIGHTCRAFT_NO_PREFS", "1") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!(out.status.success(), "{stdout}"); - assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); - assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); - } -} - -#[cfg(test)] -mod config_path_tests { - use super::*; - - #[test] - fn config_override_keeps_panic_log_in_isolated_profile() { - let temp = std::path::PathBuf::from("temp"); - assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log")); - assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log")); - assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log")); - } -} +mod tests; diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index e9323a210..a5a14e208 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -11,6 +11,11 @@ //! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the //! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come //! back when the field loses focus. +//! +//! Menu events never pass through the egui shortcut handler. [`NativeMenu::update`] therefore runs +//! every queued command through [`lightcraft_ui_egui::menubar::run_native_command`]: while the +//! blocking library window or a quit/settings notice owns input it drops the command (Settings… +//! and About included), and only Quit stays available for the host's existing confirmation. use std::collections::{HashMap, HashSet}; use std::sync::mpsc::{Receiver, channel}; @@ -480,17 +485,21 @@ impl NativeMenu { lightcraft_ui_egui::i18n::set_language(app.ui.language); let mut quit = false; while let Ok(key) = self.rx.try_recv() { - if key == QUIT { + // Resolve the native id to its command, then let the shared policy decide. Native menu + // events never reach `shortcuts::handle`, so this is the one guard for AppKit's + // accelerators and clicks: while the blocking library window or a quit/settings notice + // owns input, a queued command is dropped (Quit stays for the existing confirmation). + let (cmd, params) = if key == QUIT { + (key, serde_json::json!({})) + } else if key == "app.about" || key == SETTINGS { + (key, serde_json::json!({})) + } else if let Some(it) = self.items.get(&key) { + (it.cmd.clone(), it.params.clone()) + } else { + continue; + }; + if lightcraft_ui_egui::menubar::run_native_command(app, &cmd, params) == lightcraft_ui_egui::menubar::NativeCommand::Quit { quit = true; - continue; - } - if key == "app.about" || key == SETTINGS { - let _ = app.run(&key, serde_json::json!({})); - continue; - } - if let Some(it) = self.items.get(&key) { - let (cmd, params) = (it.cmd.clone(), it.params.clone()); - let _ = lightcraft_ui_egui::menubar::run_item(app, &cmd, params); } } let bar = menu_bar(app); diff --git a/apps/lightcraft/src/platform.rs b/apps/lightcraft/src/platform.rs new file mode 100644 index 000000000..3d4d87896 --- /dev/null +++ b/apps/lightcraft/src/platform.rs @@ -0,0 +1,57 @@ +/// The Windows command that opens `url` in the default browser. `explorer ` opened File +/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link +/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so +/// `?`, `&` and `#` need no escaping. +pub(crate) fn windows_open_url_command(url: &str) -> std::process::Command { + let mut c = std::process::Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]); + c +} + +/// Explorer's `/select,` switch (issue #272). Explorer parses its own command line and only +/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`), +/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that +/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain +/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line +/// always holds exactly this one argument. +pub(crate) fn explorer_select_arg(path: &str) -> String { + let path = path.replace('/', "\\").replace('"', ""); + let path = match path.strip_prefix(r"\\?\UNC\") { + Some(unc) => format!(r"\\{unc}"), + None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(), + }; + format!("/select,\"{path}\"") +} + +/// `explorer /select,""`. Rust's own argument quoting wraps the whole switch in quotes +/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't +/// recognise, so on Windows the switch goes on the command line exactly as built. +pub(crate) fn explorer_select_command(path: &str) -> std::process::Command { + let mut c = std::process::Command::new("explorer"); + let arg = explorer_select_arg(path); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.raw_arg(arg); + } + #[cfg(not(windows))] + { + c.arg(arg); + } + c +} + +/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is +/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray +/// top-level ("Failed to associate portal window with parent window" in the portal's log): a +/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so +/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself; +/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including +/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog +/// becomes a sheet and on Windows a modal, a behaviour change this does not make. +pub(crate) static DIALOG_PARENT: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// A file dialog owned by the app's window, or a plain one before the window exists. +pub(crate) fn file_dialog() -> rfd::FileDialog { + DIALOG_PARENT.get().cloned().unwrap_or_default() +} diff --git a/apps/lightcraft/src/prefs.rs b/apps/lightcraft/src/prefs.rs new file mode 100644 index 000000000..0800e6f3e --- /dev/null +++ b/apps/lightcraft/src/prefs.rs @@ -0,0 +1,245 @@ +use super::*; + +/// 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. +pub(crate) fn display_profile_dir() -> Option { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + let user = |rel: &str| home.as_ref().map(|h| h.join(rel)); + let candidates: Vec> = if cfg!(target_os = "macos") { + vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())] + } else if cfg!(windows) { + let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + vec![Some(root.join("System32").join("spool").join("drivers").join("color"))] + } else { + let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share")); + vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())] + }; + candidates.into_iter().flatten().find(|d| d.is_dir()) +} + +pub(crate) fn config_dir() -> Option { + lightcraft_engine::config::config_dir() +} + +/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size +/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it +/// opens is the system's to say. +pub(crate) fn as_remembered(builder: egui::ViewportBuilder, window: Option) -> egui::ViewportBuilder { + let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size)); + // (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen + // to a size of its own choosing) + if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder } +} + +/// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or +/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. +pub(crate) fn prefs_path() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("ui.json")) +} + +/// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user +/// when the file exists but can't be used (issue #103): a damaged file is kept as +/// `ui.json.corrupt-` first, so the next save can't lose the library location in it; +/// one that can't be read at all is not written this session (`keep_file`). +/// +/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing +/// (issue #164), so a damaged file is left as it is there, not set aside. +pub(crate) fn load_prefs(in_memory: bool) -> (Option, Option, bool) { + let Some(path) = prefs_path() else { return (None, None, false) }; + load_prefs_at(&path, !in_memory) +} + +/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An +/// arrangement from an older docking version starts again from the default without a warning. +fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { + let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; + let fields = saved.as_object_mut().ok_or("settings must be an object")?; + lightcraft_ui_egui::docking::drop_outdated(fields); + let recovered = + fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { + Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), + Ok(None) => false, + Err(_) => true, + }); + if recovered { + fields.remove("docking"); + } + let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?; + Ok((ui, recovered)) +} + +pub(crate) fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false), + Err(e) => { + let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display()); + return (None, Some(msg), true); + } + }; + match decode_saved_ui(&bytes) { + Ok((ui, false)) => (Some(ui.sanitized()), None, false), + Ok((ui, true)) => { + let msg = format!( + "The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.", + path.display() + ); + (Some(ui.sanitized()), Some(msg), true) + } + Err(e) if !set_aside_damaged => { + let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display()); + (None, Some(msg), true) + } + Err(e) => { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let keep = path.with_file_name(format!("ui.json.corrupt-{secs}")); + let kept = std::fs::rename(path, &keep); + let what = match &kept { + Ok(()) => format!("it was kept as {}", keep.display()), + Err(r) => format!("it couldn't be set aside ({r})"), + }; + let msg = format!( + "The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.", + path.display() + ); + (None, Some(msg), kept.is_err()) + } + } +} + +/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full +/// disk leaves the old file or the new one, never a truncated one). +pub(crate) fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let tmp = path.with_extension("json.tmp"); + let written = (|| { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all() + })(); + if let Err(e) = written { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + std::fs::rename(&tmp, path)?; + if let Some(dir) = path.parent() + && let Ok(d) = std::fs::File::open(dir) + { + let _ = d.sync_all(); + } + Ok(()) +} + +/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library +/// changes, every few seconds when anything else changed, and at exit. +/// +/// Nothing is written while the session is one that promises to save nothing: `--memory` +/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the +/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the +/// library it opened is saved then). +#[derive(Default)] +pub(crate) struct PrefsWriter { + /// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder). + pub(crate) path: Option, + pub(crate) written: Vec, + pub(crate) library: String, + pub(crate) checked: f64, + /// The last write failed (reported once until a write works). + pub(crate) failing: bool, + /// `ui.json` couldn't be read at launch: never overwrite it this session. + pub(crate) keep_file: bool, + /// Follows the window, to remember the size it rests at (`UiState::window`). + pub(crate) window: lightcraft_ui_egui::window_place::WindowWatch, + /// When the window was first looked at: its watch goes by the clock from then on, not by + /// the interface's time, which stands still while the window is hidden. + pub(crate) window_since: Option, +} + +impl PrefsWriter { + pub(crate) fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { + self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing)) + } + + /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be + /// opened) saves nothing. + pub(crate) fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> { + if self.keep_file || temporary { + return Ok(()); + } + let Some(path) = self.path.clone() else { return Ok(()) }; + if let Some(workspace) = &ui.docking { + lightcraft_ui_egui::docking::validate(workspace)?; + } + let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; + if bytes == self.written { + return Ok(()); + } + path.parent() + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| write_atomic(&path, &bytes)) + .map_err(|e| format!("saving the app settings failed: {e}"))?; + self.written = bytes; + self.library = ui.settings.library_path.clone(); + Ok(()) + } + + /// What to remember of the window as the app quits ([`WindowWatch::at_exit`]). + /// + /// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit + pub(crate) fn window_at_exit( + &mut self, + remembered: Option, + ) -> Option { + match self.window_since { + Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()), + None => remembered, + } + } + + /// Per-frame host tick: records the window's placement while visible, asks for a repaint when + /// a remembered size is due, and saves settings/library at most every three seconds or + /// immediately after the library path changes; a failed save is reported once and retried. + pub(crate) fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { + // (asked for before the input is read: the context is not asked from inside its own lock) + let zoom = ctx.zoom_factor(); + // The monotonic app clock: `i.time` stands still while the window is hidden. + let now = app.tick_now(); + let (hidden, seen) = ctx.input(|i| { + let hidden = i.viewport().visible() == Some(false); + (hidden, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom)) + }); + // how the window is, to open like that next time (written with the settings below). What + // the settings hold is checked first: a script may have set it (`ui.set`). A hidden + // `run_logic` pass has no fresh content rect: keep the last known placement. + let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); + let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64(); + if !hidden { + app.ui.window = self.window.note(remembered, &seen, at); + } + if let Some(wait) = self.window.waits(at) { + // (a size is only remembered once the window has rested at it: look again then, though + // nothing else asks for a frame) + ctx.request_repaint_after(wait); + } + let moved = app.ui.settings.library_path != self.library; + if !moved && now - self.checked < 3.0 { + return; + } + self.checked = now; + match self.save(app) { + Ok(()) => self.failing = false, + Err(e) => { + if !self.failing { + log::warn!("{e}"); + app.notices.push(format!("{e}. LightCraft keeps trying.")); + } + self.failing = true; + // don't retry every frame + self.library = app.ui.settings.library_path.clone(); + } + } + } +} diff --git a/apps/lightcraft/src/session.rs b/apps/lightcraft/src/session.rs new file mode 100644 index 000000000..ce3ab95b7 --- /dev/null +++ b/apps/lightcraft/src/session.rs @@ -0,0 +1,297 @@ +#[cfg(any(target_os = "freebsd", target_os = "linux"))] +use super::platform::DIALOG_PARENT; +use super::platform::{explorer_select_command, file_dialog, windows_open_url_command}; +use super::prefs::display_profile_dir; +use super::*; + +/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints +/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder. +pub(crate) fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services { + let ctx = cc.egui_ctx.clone(); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc)); + Services { + // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread + // stopped the window answering the compositor, which then reported the app as hung. + picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { + use lightcraft_ui_egui::pick::PickKind; + let (tx, rx) = std::sync::mpsc::channel(); + let repaint = ctx.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + let mut d = file_dialog(); + if let Some(t) = &req.title { + d = d.set_title(t); + } + if let Some((name, exts)) = &req.filter { + d = d.add_filter_nocase(name.clone(), exts); + } + if let Some(n) = &req.file_name { + d = d.set_file_name(n); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + let paths: Vec = match req.kind { + PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(), + PickKind::File => d.pick_file().map(s).into_iter().collect(), + PickKind::Folder => d.pick_folder().map(s).into_iter().collect(), + PickKind::Save => d.save_file().map(s).into_iter().collect(), + }; + // the app may have quit meanwhile: nobody to tell + let _ = tx.send(paths); + repaint.request_repaint(); + }) + .map_err(|e| format!("could not start the file dialog: {e}"))?; + Ok(rx) + })), + log_file: log_file.map(|p| p.display().to_string()), + pick_lightroom_catalog: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_folder: Some(Box::new(|| { + file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) + })), + #[cfg(target_os = "linux")] + open_model_folder: Some(std::sync::Arc::new(|path: &str| { + std::process::Command::new("xdg-open") + .arg(path) + .status() + .map_err(|e| e.to_string()) + .and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) }) + })), + open_with: Some(Box::new(editors::open_with)), + open_all_with: Some(Box::new(editors::open_all_with)), + check_editor: Some(Box::new(editors::check)), + open_url: Some(Box::new(|url: &str| { + if !url.starts_with("https://") { + return Err("only https links are opened".into()); + } + let status = if cfg!(target_os = "macos") { + std::process::Command::new("open").arg(url).status() + } else if cfg!(target_os = "windows") { + windows_open_url_command(url).status() + } else { + std::process::Command::new("xdg-open").arg(url).status() + }; + status.map(|_| ()).map_err(|e| e.to_string()) + })), + reveal: Some(Box::new(|path: &str| { + let status = if cfg!(target_os = "macos") { + std::process::Command::new("open").args(["-R", path]).status() + } else if cfg!(target_os = "windows") { + explorer_select_command(path).status() + } else { + let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into()); + std::process::Command::new("xdg-open").arg(dir).status() + }; + status + .map_err(|e| e.to_string()) + .and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) }) + })), + pick_files: Some(Box::new(|| { + file_dialog() + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + pick_denoise_model: Some(Box::new(|| { + file_dialog() + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().into_owned()]) + .unwrap_or_default() + })), + pick_preset_files: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) + .add_filter_nocase( + lightcraft_ui_egui::i18n::tr("Presets & Profiles"), + &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"], + ) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + pick_model_file: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), + pick_tracklog: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), + // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) + #[cfg(target_os = "macos")] + screen_profile: Some(Box::new(screen_profile::icc)), + // Linux: the profile the desktop assigns in colord, read off the UI thread + #[cfg(target_os = "linux")] + screen_profile: Some(colord::service()), + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + screen_profile: None, + pick_display_profile: Some(Box::new(|| { + let mut d = file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]); + if let Some(dir) = display_profile_dir() { + d = d.set_directory(dir); + } + d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default() + })), + save_preset_file: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_keyword_list: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Keywords")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), + save_keyword_list: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Keywords")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_curve_preset_files: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_curve_preset_file: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_slideshow_templates: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_slideshow_template: Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_print_templates: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_print_template: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Print Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + // atomic (temp file + sync + rename): a failed write never leaves a truncated file + write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), + write: Some(Box::new(lightcraft_engine::export::write_file)), + write_new: Some(Box::new(lightcraft_engine::export::write_file_new)), + png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + })), + // the library is a folder on disk: backed up with the user's other files + backup_library: None, + restore_library: None, + system_theme: appearance::service(), + } +} + +/// The library session with the folders face models (`/models`) and slideshow templates +/// (`/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet: +/// the window lists the recent ones. +pub(crate) fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { + let (mut s, problem) = match launch { + Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), + Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), + }; + s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); + s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); + // Control requests are answered on the UI thread: Add to Apple Photos must not wait there + s.apple_photos_background = true; + (s, problem) +} + +/// The persistent library session, or with `--memory` an in-memory one (demo photos). +/// +/// If the library can't be opened the session is empty and in memory — never seeded with the +/// demo photos, never written anywhere — and the problem is returned: the window then says so +/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100). +fn open_library_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { + if in_memory { + return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None); + } + let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock(); + let Some(dir) = dir else { + log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)"); + return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); + }; + let t0 = std::time::Instant::now(); + let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock(); + match s.open_library(&dir, seed_demo) { + Ok(r) => { + let (replayed, torn) = (r.replayed, r.torn_bytes); + log::info!( + "library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", + dir.display(), + s.catalog.len(), + if torn > 0 { ", torn tail repaired" } else { "" }, + t0.elapsed().as_secs_f64() * 1000.0 + ); + (s, None) + } + Err(e) => { + log::error!("can't open library {}: {e}", dir.display()); + (unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string()))) + } + } +} diff --git a/apps/lightcraft/src/tests.rs b/apps/lightcraft/src/tests.rs new file mode 100644 index 000000000..0afbc4b2c --- /dev/null +++ b/apps/lightcraft/src/tests.rs @@ -0,0 +1,464 @@ +use super::*; + +fn dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// Issue #374: launch arguments that would walk a whole drive or home folder are reported, +/// not imported; empty ones are ignored; the rest are imported as absolute paths. +#[test] +fn launch_arguments_never_walk_a_drive_or_the_home_folder() { + let home = dir("launch"); + std::fs::create_dir_all(home.join("Trip")).unwrap(); + let args: Vec = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect(); + let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home)); + assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]); + assert_eq!(notices.len(), 2, "{notices:?}"); + assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}"); + // the working folder is a drive root (a launcher's choice): `.` is never the whole drive + let root = home.ancestors().last().unwrap().to_path_buf(); + let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None); + assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}"); + let _ = std::fs::remove_dir_all(&home); +} + +/// Issue #236: the window answers control requests on its UI thread, so the desktop session +/// adds to Apple Photos on a worker (headless sessions wait instead). +#[test] +fn the_desktop_session_adds_to_apple_photos_in_the_background() { + let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); + assert!(problem.is_none()); + assert!(s.apple_photos_background); + assert!(!lightcraft_engine::Session::new().apple_photos_background); +} + +/// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo +/// photos) before the user chooses, and the settings are still saved while the list shows (a +/// removed entry is kept), unlike the failed-library window's temporary session. +#[test] +fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { + let (s, problem) = open_session(false, &Launch::Ask, true); + assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty()); + assert!(s.apple_photos_background, "the same desktop session as an opened one"); + + let d = dir("writer-ask"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(s, Services::default()); + app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + app.ui.settings.recent_libraries.remove(0); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + // the failed-library window still writes nothing + app.library_problem = Some(LibraryProblem::new("/gone/b", "not there")); + app.ui.settings.recent_libraries.clear(); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and +/// reported, not silently replaced by the defaults. +#[test] +fn damaged_ui_json_is_kept_and_reported() { + let d = dir("damaged"); + let path = d.join("ui.json"); + std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap(); + let (ui, warning, keep) = load_prefs_at(&path, true); + assert!(ui.is_none() && !keep); + let warning = warning.unwrap(); + assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}"); + assert!(!path.exists()); + let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}"); + // a missing file is just "no settings yet" + assert_eq!(load_prefs_at(&path, true).1, None); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is +/// reported and left where it is, not set aside. +#[test] +fn memory_session_leaves_a_damaged_ui_json_alone() { + let d = dir("damaged-memory"); + let path = d.join("ui.json"); + let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#; + std::fs::write(&path, bytes).unwrap(); + let (ui, warning, keep) = load_prefs_at(&path, false); + assert!(ui.is_none() && keep); + let warning = warning.unwrap(); + assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn docking_survives_native_settings_file_reload() { + use lightcraft_ui_egui::docking::Panel; + let d = dir("docking-reload"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save_ui(&app.ui, false).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, false); + assert!(warning.is_none()); + assert!(!keep); + let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + restored_app.ui = restored.unwrap(); + restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap(); + assert_eq!( + restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, + [70.0, 80.0, 310.0, 500.0] + ); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn future_docking_preserves_library_settings_and_the_original_preferences_file() { + let d = dir("future-docking"); + let path = d.join("ui.json"); + let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() }; + ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into(); + for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] { + let mut saved = serde_json::to_value(&ui).unwrap(); + saved["docking"] = docking; + assert!(serde_json::from_value::(saved.clone()).is_err(), "runtime state parsing stays strict"); + let bytes = serde_json::to_vec(&saved).unwrap(); + for set_aside in [false, true] { + std::fs::write(&path, &bytes).unwrap(); + let (loaded, warning, keep) = load_prefs_at(&path, set_aside); + let loaded = loaded.unwrap(); + assert_eq!(loaded.settings.library_path, ui.settings.library_path); + assert_eq!(loaded.thumb_size, ui.thumb_size); + assert!(!loaded.filmstrip); + assert!(loaded.docking.is_none()); + assert!(warning.unwrap().contains("Other settings were loaded")); + assert!(keep); + let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() }; + writer.save_ui(&loaded, false).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file"); + } + } + std::fs::remove_dir_all(d).unwrap(); +} + +/// Issue #164: the writer saves changed settings to its file — and nothing at all without one +/// (`--memory`) or while the session is the temporary one offered when the library can't open. +#[test] +fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() { + let d = dir("writer"); + let path = d.join("sub").join("ui.json"); + let mut ui = UiState::default(); + // an ordinary session: a change is written, the folder created on the way + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + let saved = std::fs::read(&path).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved)); + // the temporary session (library problem): a further change is not written… + ui.thumb_size += 50.0; + w.save_ui(&ui, true).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), saved); + // …until a library opens + w.save_ui(&ui, false).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), saved); + // --memory: no path, so no file and no folder, whatever changes + let d2 = dir("writer-memory"); + let _ = std::fs::remove_dir_all(&d2); + let mut w = PrefsWriter { path: None, ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + assert!(w.written.is_empty() && !d2.exists()); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create +/// the settings folder) nor removes a marker a crashed launch left; it still reports it, and +/// the next ordinary launch finds it, reports it and clears it. +#[test] +fn memory_session_leaves_the_gpu_marker_and_folder_alone() { + let d = dir("gpu-marker-memory"); + let _ = std::fs::remove_dir_all(&d); + let m = d.join("gpu-init.marker"); + // nothing left behind: no notice, and the folder is not created + assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None); + assert!(!d.exists(), "a --memory session must not create the settings folder"); + // a marker left by a crashed launch: reported, kept + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap(); + let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap(); + assert!(notice.contains("GPU rendering is now off"), "{notice}"); + assert!(m.exists(), "the marker stays for the next ordinary launch"); + // the next ordinary launch reports it once and removes it + assert!(gpu_crash_check_at(Some(m.clone()), false).is_some()); + lightcraft_engine::gpu::backend::set_init_marker(None); + assert!(!m.exists()); + assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None); + lightcraft_engine::gpu::backend::set_init_marker(None); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an +/// export, and the dialog said the window "could not open". The stop is recorded; the next +/// launch says why once and renders on the CPU (a `--memory` session leaves the record). +#[test] +fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() { + let d = dir("gpu-lost"); + let m = d.join("gpu-lost.marker"); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded"); + assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder"); + record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost"); + let notice = gpu_lost_check_at(Some(&m), true).unwrap(); + assert!( + notice.contains("(device lost (Unknown): Device is lost)") + && notice.contains("GPU rendering is now off") + && notice.contains("Use the GPU for rendering"), + "{notice}" + ); + assert!(m.exists(), "a --memory session leaves it"); + assert!(gpu_lost_check_at(Some(&m), false).is_some()); + assert!(!m.exists()); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once"); + // the message box: LightCraft stopped (not "could not open its window"), and why + let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost")); + assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}"); + assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing"); + let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log")); + assert_eq!(title, "LightCraft stopped"); + assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}"); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. +#[test] +fn gpu_crash_marker_is_reported_once() { + let d = dir("gpu-marker"); + let m = d.join("gpu-init.marker"); + assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None); + std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap(); + let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap(); + let notice = gpu_crash_notice(&what); + assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}"); + assert!(!m.exists()); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default. +#[test] +fn window_backends_follow_the_platform_default() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + let b = n.instance_descriptor.backends; + if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() { + if cfg!(windows) { + assert_eq!(b, eframe::wgpu::Backends::DX12); + } else if cfg!(target_os = "macos") { + assert_eq!(b, eframe::wgpu::Backends::METAL); + } + } + assert!(!b.is_empty()); +} + +/// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any +/// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening. +#[test] +fn window_compiles_dx12_shaders_with_fxc() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + if std::env::var_os("WGPU_DX12_COMPILER").is_none() { + assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc)); + } +} + +/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline, +/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened). +#[test] +fn window_has_no_indirect_call_validation_pipeline() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); + } +} + +/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not +/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument. +#[test] +fn windows_links_open_in_the_browser() { + let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200"; + let c = windows_open_url_command(url); + assert_eq!(c.get_program(), "rundll32"); + let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); + assert_eq!(args, ["url.dll,FileProtocolHandler", url]); +} + +/// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and +/// without the verbatim prefix, even when the path has spaces (else it opens Documents). +#[test] +fn explorer_select_quotes_the_path() { + assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#); + assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#); + assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#); + assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#); + assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#); + // a `"` can't end the quoted path and add arguments to the raw command line + assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#); + let c = explorer_select_command(r"C:\My Photos\a.jpg"); + assert_eq!(c.get_program(), "explorer"); + let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); + assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]); +} + +#[test] +fn a_control_port_that_is_not_a_number_is_ignored() { + assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980)); + assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001)); + assert_eq!(control_port_from("--control", Some("nope".into())), None); + assert_eq!(control_port_from("--control", Some(String::new())), None); + assert_eq!(control_port_from("--control", Some("70000".into())), None); + assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None); +} + +#[test] +fn ui_json_is_written_atomically() { + let d = dir("atomic"); + let path = d.join("ui.json"); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"{\"new\": true}").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); + assert!(!d.join("ui.json.tmp").exists()); + // a failed write leaves the old file + let missing = d.join("gone").join("ui.json"); + assert!(write_atomic(&missing, b"x").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); + let ui = UiState::default(); + write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap(); + let (loaded, warning, _) = load_prefs_at(&path, true); + assert!(loaded.is_some() && warning.is_none()); + let _ = std::fs::remove_dir_all(&d); +} + +/// Given what the settings remember of the window, when the app's window is built, then it +/// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it +/// opens at the usual size. +#[test] +fn the_window_opens_as_it_was() { + use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace}; + let fresh = as_remembered(egui::ViewportBuilder::default(), None); + assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None)); + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true))); + // (not asked to un-zoom: macOS would then resize a window that fills its screen) + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None)); +} + +/// Given a window that rests at a size, when the settings are written and read again, then +/// they hold how it was. A window in the file that is not one (a hand edit, another version's) +/// reads as nothing remembered and costs no other setting; and a window resized just before +/// the app quits is remembered at that size. +#[test] +fn the_settings_keep_how_the_window_was() { + use lightcraft_ui_egui::window_place::{Seen, WindowPlace}; + let d = dir("window"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.ui.settings.library_path = "/pictures/library".into(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save(&app).unwrap(); + assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet"); + let window = WindowPlace { size: [1300.0, 800.0], zoomed: true }; + app.ui.window = Some(window); + writer.save(&app).unwrap(); + let (restored, warning, _) = load_prefs_at(&path, false); + assert_eq!((restored.unwrap().window, warning), (Some(window), None)); + + // a window that is no window: the file is read all the same, with its other settings + let text = std::fs::read_to_string(&path).unwrap(); + for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] { + let start = text.find("\"window\"").unwrap(); + let end = start + text[start..].find('}').unwrap() + 1; + std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, true); + let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}")); + assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}"); + } + + // resized, and the app quits a moment later: the new size is what is written + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }); + writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0); + writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400)); + assert!(writer.window_since.is_some()); + assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false })); + assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen"); + let _ = std::fs::remove_dir_all(&d); +} + +/// Given the app's window at a size, when the settings writer looks at it frame after frame, +/// then it asks to be woken while the size has still to rest, remembers the size once it has, +/// and asks for nothing more. A window in the settings that is not one (a script set it) is +/// put right before it is gone by. +#[test] +fn the_writer_follows_the_window_from_frame_to_frame() { + use lightcraft_ui_egui::window_place::WindowPlace; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut writer = PrefsWriter::default(); + let ctx = egui::Context::default(); + let pass = |look: &mut dyn FnMut(&egui::Context)| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) + }; + // (egui's own first frames ask for another: let them pass) + for _ in 0..3 { + pass(&mut |_| {}); + } + app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true }); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, None, "no window, and not yet at rest"); + assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}"); + // a second later + writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1))); + pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}"); +} + +/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. +/// The check runs in a child (this test binary again), since on macOS it ends the process. +#[test] +fn on_exit_ends_the_process_on_macos() { + if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut app = App( + app, + PrefsWriter::default(), + #[cfg(target_os = "macos")] + None, + ); + println!("calling on_exit"); + eframe::App::on_exit(&mut app, None); + println!("on_exit returned"); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"]) + .env("LIGHTCRAFT_TEST_ON_EXIT", "1") + .env("LIGHTCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{stdout}"); + assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); + assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); +} diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 30eea5654..e5ed8090e 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -8,6 +8,10 @@ covers a whole directory), or if a licence file referenced in the Licence column | Path | Asset | Creator | Source | Licence | Added | Modifications | |---|---|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | egui icon subset and monochrome fallback | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | 2026-10-24 (notice audit) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full fallback. Registered by production default font definitions; dependency assets unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Hack Regular (the monospace default) | Source Foundry Authors (built on the DejaVu project, which was committed to the public domain) and Bitstream Inc. | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | MIT AND Bitstream Vera License (Copyright (c) 2018 Source Foundry Authors; Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.; full upstream texts in `NOTICE`) | 2026-10-25 (notice audit) | unmodified egui default font embedded in the binary and registered by the production default font definitions (egui's `default_fonts`); no font file copied into this repository | +| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Ubuntu Light (the proportional default) | Canonical Ltd.; designed by Dalton Maag Ltd | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | Ubuntu Font Licence 1.0, not the SIL Open Font License (Copyright 2011 Canonical Ltd.; full upstream text in `NOTICE`) | 2026-10-25 (notice audit) | unmodified egui default font embedded in the binary and registered by the production default font definitions (egui's `default_fonts`); no font file copied into this repository | +| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | Noto Emoji Regular | Google Inc.; designed by the Monotype Design Team | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | SIL Open Font License 1.1 (Copyright 2013 Google Inc. All Rights Reserved.; full upstream text in `NOTICE`) | 2026-10-25 (notice audit) | unmodified egui default font embedded in the binary and registered by the production default font definitions (egui's `default_fonts` and `monochrome_emoji_fonts`); no font file copied into this repository | | `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none | | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none | @@ -18,6 +22,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/ILCE-7RM5.json` | Sony ILCE-7RM5 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Yule (Brian) Wang (@peaperfish) | original work: fitted by `lightcraft-cli calibrate` to 220 usable ILCE-7RM5 ARWs of my own (of 241, 2025 to 2026, Standard creative style; Sigma 24-70mm F2.8 DG DN II Art and Sony FE 200-600mm F5.6-6.3 G OSS) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-26 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | @@ -29,7 +34,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | -| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file | +| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular, Noto Sans Georgian Regular, NanumGothic Regular, Noto Sans Symbols Regular, Noto Sans Symbols 2 Regular, Noto Sans Hebrew Regular, Noto Sans Devanagari Regular | The Noto Project Authors (Arabic, Thai, Georgian, Symbols, Symbols 2, Hebrew and Devanagari); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; NHN Corporation and Sandoll Communications (NanumGothic); Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the default `fonts/manifest.txt` set (all official releases; the opt-in LXGW WenKai Lite face in `fonts/simplified-chinese-manifest.txt` is not embedded); the web build embeds BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | | `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending | | `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders | @@ -40,6 +45,9 @@ covers a whole directory), or if a licence file referenced in the Licence column | `crates/ui-egui/locales/vi.json`, `crates/ui-egui/locales/vi-formats.json` | Vietnamese interface catalogs | @duydev (DUY Tran Nhat) with AI assistance (Cursor) | original work: translations of English source phrases; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-13 | plural-neutral counts, Vietnamese dates and weekdays, reviewed placeholders | Notes +- egui's default fonts (Hack, Ubuntu Light, Noto Emoji, egui-icons and emoji-icon-font) come from the + `epaint_default_fonts` dependency, not from files in this repository; their creators, licences and the full + upstream texts are recorded in the rows above and in `NOTICE`. - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter. - CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in diff --git a/assets/camera-profiles/ILCE-7RM5.json b/assets/camera-profiles/ILCE-7RM5.json new file mode 100644 index 000000000..787bfb062 --- /dev/null +++ b/assets/camera-profiles/ILCE-7RM5.json @@ -0,0 +1,9031 @@ +{ + "version": 1, + "model": "ILCE-7RM5", + "files": 220, + "samples": 790553, + "matrix": [ + [ + 1.249584732786861, + -0.3300570108987344, + 0.07738524309065298 + ], + [ + -0.09371116474358132, + 1.2038743929103433, + -0.1120131503658719 + ], + [ + -0.03422832387281005, + -0.8688172281950557, + 1.937872050399747 + ] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.6575432, + 1.0169075, + 1.0 + ], + [ + 0.045065638, + 1.0544671, + 1.0 + ], + [ + 0.2621381, + 1.0540911, + 1.0 + ], + [ + 0.5927048, + 1.0288227, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.568119, + 1.0037564, + 1.0 + ], + [ + -0.4427149, + 1.0345306, + 1.0 + ], + [ + -0.41775778, + 1.0446787, + 1.0 + ], + [ + 0.086332865, + 1.0200883, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.272573, + 1.0058666, + 1.0 + ], + [ + -0.5253304, + 0.9976382, + 1.0 + ], + [ + -1.19747, + 1.0018761, + 1.0 + ], + [ + -0.79130363, + 0.98982924, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.8305438, + 1.013145, + 1.0 + ], + [ + -0.64092255, + 1.0028768, + 1.0 + ], + [ + -1.6476865, + 0.98830545, + 1.0 + ], + [ + -1.8518519, + 0.9801241, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.9341725, + 1.02301, + 1.0 + ], + [ + -1.0185463, + 1.0157639, + 1.0 + ], + [ + -2.5776067, + 0.99842805, + 1.0 + ], + [ + -3.2994924, + 0.9785849, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.5423042, + 1.0483397, + 1.0 + ], + [ + -1.4740537, + 1.032285, + 1.0 + ], + [ + -3.7110608, + 1.0046949, + 1.0 + ], + [ + -5.396528, + 0.967783, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.6425831, + 1.0778131, + 1.0 + ], + [ + -2.4365096, + 1.0424513, + 1.0 + ], + [ + -4.570945, + 1.0063766, + 1.0 + ], + [ + -6.660486, + 0.95838326, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.14437525, + 1.0805831, + 1.0 + ], + [ + -3.1429949, + 1.0347922, + 1.0 + ], + [ + -4.7743, + 1.0044911, + 1.0 + ], + [ + -6.743695, + 0.9554359, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.7822553, + 1.099207, + 1.0 + ], + [ + -2.1562428, + 1.0492018, + 1.0 + ], + [ + -4.6814117, + 0.9976052, + 1.0 + ], + [ + -7.334759, + 0.9328078, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4186356, + 1.1193541, + 1.0 + ], + [ + -1.2648852, + 1.0724756, + 1.0 + ], + [ + -4.010651, + 1.001225, + 1.0 + ], + [ + -6.9021163, + 0.93410987, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0874636, + 1.1538868, + 1.0 + ], + [ + 0.23989654, + 1.0897437, + 1.0 + ], + [ + -2.9069233, + 1.0008082, + 1.0 + ], + [ + -6.942227, + 0.9103677, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.2319875, + 1.1964883, + 1.0 + ], + [ + 1.6361779, + 1.1026742, + 1.0 + ], + [ + -1.7622405, + 0.99407303, + 1.0 + ], + [ + -7.6075315, + 0.87470096, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.200221, + 1.2336057, + 1.0 + ], + [ + 2.4819555, + 1.1164554, + 1.0 + ], + [ + -0.67436194, + 0.9800342, + 1.0 + ], + [ + -6.7233605, + 0.8354304, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.7635767, + 1.258256, + 1.0 + ], + [ + 2.755172, + 1.1187295, + 1.0 + ], + [ + 0.58612347, + 0.95909715, + 1.0 + ], + [ + -3.4647129, + 0.8201187, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7450001, + 1.2654432, + 1.0 + ], + [ + 2.2866368, + 1.1127598, + 1.0 + ], + [ + 1.7782547, + 0.96788186, + 1.0 + ], + [ + 0.64800644, + 0.8437623, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4756403, + 1.2495352, + 1.0 + ], + [ + 1.8023143, + 1.1058493, + 1.0 + ], + [ + 2.4051156, + 0.97594136, + 1.0 + ], + [ + 3.1766913, + 0.8713939, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.15415932, + 1.2347906, + 1.0 + ], + [ + 1.3704019, + 1.0905327, + 1.0 + ], + [ + 2.8927534, + 0.95705473, + 1.0 + ], + [ + 4.9556255, + 0.85762197, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.45234466, + 1.2126856, + 1.0 + ], + [ + 1.3297142, + 1.0610285, + 1.0 + ], + [ + 3.5847495, + 0.92722595, + 1.0 + ], + [ + 6.0845156, + 0.84810686, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.0763642, + 1.1915482, + 1.0 + ], + [ + 1.087915, + 1.0379584, + 1.0 + ], + [ + 3.107092, + 0.9336573, + 1.0 + ], + [ + 4.6096416, + 0.87201273, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.8927168, + 1.1797738, + 1.0 + ], + [ + 0.3604307, + 1.0387832, + 1.0 + ], + [ + 1.8885725, + 0.9472845, + 1.0 + ], + [ + 2.5527375, + 0.8916758, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.8154569, + 1.1746649, + 1.0 + ], + [ + -0.75731444, + 1.0539931, + 1.0 + ], + [ + 0.15679865, + 0.99418485, + 1.0 + ], + [ + 0.26365867, + 0.97036415, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.6557531, + 1.1808895, + 1.0 + ], + [ + -2.146421, + 1.0872095, + 1.0 + ], + [ + -1.216422, + 1.0318732, + 1.0 + ], + [ + -0.14712709, + 1.0002339, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.503687, + 1.1711676, + 1.0 + ], + [ + -2.6333165, + 1.0882001, + 1.0 + ], + [ + -0.9562701, + 1.0260637, + 1.0 + ], + [ + -0.035742544, + 1.0006093, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -5.3313165, + 1.1779693, + 1.0 + ], + [ + -3.511713, + 1.0934967, + 1.0 + ], + [ + -0.77989405, + 1.0125425, + 1.0 + ], + [ + -0.15130846, + 0.9985428, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.7568846, + 1.1773652, + 1.0 + ], + [ + -4.3852744, + 1.0850232, + 1.0 + ], + [ + -2.9159627, + 0.9900688, + 1.0 + ], + [ + -2.1400974, + 0.9775968, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.7892175, + 1.1440657, + 1.0 + ], + [ + -4.395312, + 1.0594687, + 1.0 + ], + [ + -3.1968632, + 0.9905179, + 1.0 + ], + [ + -1.8385077, + 0.98149014, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.615582, + 1.1392046, + 1.0 + ], + [ + -4.00538, + 1.0335472, + 1.0 + ], + [ + -0.9200221, + 0.99787354, + 1.0 + ], + [ + -0.11120706, + 0.9992187, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.523595, + 1.1210346, + 1.0 + ], + [ + -4.8383403, + 1.0227709, + 1.0 + ], + [ + -1.0064363, + 0.9959147, + 1.0 + ], + [ + -0.049839716, + 0.99975395, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.567221, + 1.109696, + 1.0 + ], + [ + -3.7778788, + 1.0227439, + 1.0 + ], + [ + -0.3094657, + 0.99754596, + 1.0 + ], + [ + -0.01160631, + 0.9998476, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.9242926, + 1.1014805, + 1.0 + ], + [ + -2.6735833, + 1.0197709, + 1.0 + ], + [ + -0.10607915, + 0.9988134, + 1.0 + ], + [ + -0.0016143449, + 0.99994075, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.489351, + 1.0988349, + 1.0 + ], + [ + -2.0020316, + 1.0113986, + 1.0 + ], + [ + -0.060160246, + 0.9993989, + 1.0 + ], + [ + -0.00045636468, + 0.99998945, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.7909346, + 1.0950327, + 1.0 + ], + [ + -3.1690855, + 1.0192778, + 1.0 + ], + [ + -0.20098883, + 1.0008932, + 1.0 + ], + [ + -0.0010797405, + 1.0000042, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.812833, + 1.0921811, + 1.0 + ], + [ + -3.6212065, + 1.016884, + 1.0 + ], + [ + -0.2872315, + 1.000208, + 1.0 + ], + [ + -0.0022921215, + 0.9999986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.7691603, + 1.0748369, + 1.0 + ], + [ + -2.2901673, + 1.0061964, + 1.0 + ], + [ + -0.2540361, + 0.9997461, + 1.0 + ], + [ + -0.002999882, + 0.9999967, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.285355, + 1.0752903, + 1.0 + ], + [ + -2.8977985, + 1.008037, + 1.0 + ], + [ + -0.389354, + 1.0001613, + 1.0 + ], + [ + -0.0048222253, + 1.0000017, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.331701, + 1.0774896, + 1.0 + ], + [ + -3.3062727, + 1.0165391, + 1.0 + ], + [ + -0.24283074, + 1.0005523, + 1.0 + ], + [ + -0.0019910862, + 1.0000027, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.720209, + 1.0690521, + 1.0 + ], + [ + -4.8786044, + 1.0175062, + 1.0 + ], + [ + -0.48560837, + 1.0014203, + 1.0 + ], + [ + -0.0033854088, + 1.0000086, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.556501, + 1.0456746, + 1.0 + ], + [ + -4.985604, + 1.0003452, + 1.0 + ], + [ + -0.41515103, + 0.9995448, + 1.0 + ], + [ + -0.003054782, + 0.9999918, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.729045, + 1.0217354, + 1.0 + ], + [ + -3.521123, + 0.98780435, + 1.0 + ], + [ + -0.30965137, + 0.99440813, + 1.0 + ], + [ + -0.008598875, + 0.9997742, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.221271, + 0.98520076, + 1.0 + ], + [ + -4.486184, + 0.97454756, + 1.0 + ], + [ + -0.41762093, + 0.9899926, + 1.0 + ], + [ + -0.03436083, + 0.9983804, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.70429, + 0.9197576, + 1.0 + ], + [ + -6.0378523, + 0.93986017, + 1.0 + ], + [ + -0.41832632, + 0.99216044, + 1.0 + ], + [ + -0.01641741, + 0.99925196, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.093513, + 0.8699881, + 1.0 + ], + [ + -6.8281274, + 0.9147011, + 1.0 + ], + [ + -0.30128792, + 0.99418074, + 1.0 + ], + [ + -0.0026745356, + 0.9999154, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.529678, + 0.84677386, + 1.0 + ], + [ + -7.347991, + 0.897593, + 1.0 + ], + [ + -0.39531615, + 0.9915446, + 1.0 + ], + [ + -0.000654911, + 0.9999173, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -10.297008, + 0.8246169, + 1.0 + ], + [ + -6.991395, + 0.8822463, + 1.0 + ], + [ + -0.8904233, + 0.98326087, + 1.0 + ], + [ + -0.005210178, + 0.99982196, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.1014814, + 0.870403, + 1.0 + ], + [ + -4.2737536, + 0.9510795, + 1.0 + ], + [ + -1.0143899, + 0.9928547, + 1.0 + ], + [ + 0.0069134138, + 0.9990259, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.2389834, + 1.0089513, + 1.0 + ], + [ + 0.59233487, + 1.0836766, + 1.0 + ], + [ + 2.109882, + 1.0953512, + 1.0 + ], + [ + 0.57811993, + 1.0165061, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.42605868, + 1.0736833, + 1.0 + ], + [ + 2.6112878, + 1.1127571, + 1.0 + ], + [ + 3.734751, + 1.1181016, + 1.0 + ], + [ + 2.895584, + 1.0769328, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.7645093, + 1.0585829, + 1.0 + ], + [ + 2.7074714, + 1.1086032, + 1.0 + ], + [ + 4.612825, + 1.121013, + 1.0 + ], + [ + 4.2159166, + 1.090578, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.317804, + 1.0096856, + 1.0 + ], + [ + 2.410078, + 1.0813844, + 1.0 + ], + [ + 4.022777, + 1.0866787, + 1.0 + ], + [ + 1.5960958, + 1.0285214, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7581048, + 0.94621634, + 1.0 + ], + [ + 3.344702, + 1.031301, + 1.0 + ], + [ + 1.6271458, + 1.0360217, + 1.0 + ], + [ + 0.045802835, + 1.0040928, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.2102604, + 0.8877613, + 1.0 + ], + [ + 2.5063229, + 0.9831511, + 1.0 + ], + [ + 1.0589613, + 1.0087296, + 1.0 + ], + [ + 0.044849876, + 1.0006486, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8181234, + 0.84908885, + 1.0 + ], + [ + 1.2979459, + 0.9558577, + 1.0 + ], + [ + 0.95269287, + 0.9990587, + 1.0 + ], + [ + 0.13956869, + 0.99899775, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.4218108, + 0.8245256, + 1.0 + ], + [ + 0.980773, + 0.95178086, + 1.0 + ], + [ + 0.44153172, + 1.004565, + 1.0 + ], + [ + 0.083737634, + 0.9998738, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.3112286, + 0.81245995, + 1.0 + ], + [ + 1.2433338, + 0.94633913, + 1.0 + ], + [ + -0.45147318, + 1.0128739, + 1.0 + ], + [ + -0.040671885, + 1.0012027, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.0959775, + 0.82676107, + 1.0 + ], + [ + 0.19708328, + 0.9494545, + 1.0 + ], + [ + -0.71126884, + 1.0130774, + 1.0 + ], + [ + -0.066266306, + 1.0014489, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0682087, + 0.85580033, + 1.0 + ], + [ + 0.33310115, + 0.9754608, + 1.0 + ], + [ + -0.038120847, + 1.0069447, + 1.0 + ], + [ + -0.0039369226, + 1.0006703, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.4665134, + 0.8869471, + 1.0 + ], + [ + 1.394241, + 0.9861353, + 1.0 + ], + [ + 0.39206934, + 1.0029631, + 1.0 + ], + [ + 0.012310251, + 1.0002207, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.840249, + 0.885747, + 1.0 + ], + [ + 2.030714, + 0.97694564, + 1.0 + ], + [ + 0.48313063, + 0.9986679, + 1.0 + ], + [ + 0.015092909, + 1.0000398, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.364709, + 0.93935746, + 1.0 + ], + [ + 3.27353, + 1.0063933, + 1.0 + ], + [ + 0.47039714, + 1.0006171, + 1.0 + ], + [ + 0.0095254835, + 0.9999786, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.708454, + 0.9936273, + 1.0 + ], + [ + 3.0144715, + 1.0396764, + 1.0 + ], + [ + 0.76019174, + 1.0015196, + 1.0 + ], + [ + 0.031606715, + 0.9997963, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.081264, + 1.007644, + 1.0 + ], + [ + 0.9569218, + 1.0373055, + 1.0 + ], + [ + -0.29632938, + 1.0081192, + 1.0 + ], + [ + -0.020595783, + 1.0002273, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.4333196, + 0.9922466, + 1.0 + ], + [ + 0.8624292, + 1.0051231, + 1.0 + ], + [ + -0.124487296, + 1.0007266, + 1.0 + ], + [ + 0.0014794656, + 1.0001725, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.0025764, + 0.9966404, + 1.0 + ], + [ + 0.32528183, + 1.0222802, + 1.0 + ], + [ + -1.4467791, + 1.0029583, + 1.0 + ], + [ + -0.09876483, + 1.0001086, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.9974155, + 0.9894437, + 1.0 + ], + [ + 2.0329673, + 1.0394019, + 1.0 + ], + [ + -0.49002603, + 1.0067779, + 1.0 + ], + [ + -0.091952406, + 0.9999813, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.90883, + 0.9736301, + 1.0 + ], + [ + 2.7153323, + 1.0357952, + 1.0 + ], + [ + 0.37018985, + 1.0083603, + 1.0 + ], + [ + -0.0096038, + 1.0001452, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.1170373, + 0.98292226, + 1.0 + ], + [ + 1.2927599, + 1.035257, + 1.0 + ], + [ + 0.3150436, + 1.0090348, + 1.0 + ], + [ + 0.0004185312, + 1.0000962, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.3353724, + 0.9857392, + 1.0 + ], + [ + 0.9401049, + 1.0468453, + 1.0 + ], + [ + -0.13837458, + 1.0182233, + 1.0 + ], + [ + -0.024829892, + 1.0004286, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.5100236, + 1.0068982, + 1.0 + ], + [ + 0.93617123, + 1.0617714, + 1.0 + ], + [ + 0.047028802, + 1.0331767, + 1.0 + ], + [ + 0.0046950956, + 1.0010607, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.6971107, + 1.0113055, + 1.0 + ], + [ + -0.20543085, + 1.0561123, + 1.0 + ], + [ + -0.47802702, + 1.0383303, + 1.0 + ], + [ + 0.07525184, + 1.0021627, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.8135715, + 1.0148535, + 1.0 + ], + [ + 0.23203266, + 1.0550131, + 1.0 + ], + [ + -0.023389658, + 1.04285, + 1.0 + ], + [ + 0.19465668, + 1.002981, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.6685414, + 1.0124575, + 1.0 + ], + [ + 0.59984475, + 1.0448725, + 1.0 + ], + [ + 0.69587445, + 1.0372711, + 1.0 + ], + [ + 0.60554105, + 1.005815, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.5002728, + 1.0161368, + 1.0 + ], + [ + 0.56248856, + 1.058378, + 1.0 + ], + [ + 0.9347412, + 1.0511781, + 1.0 + ], + [ + 0.8927735, + 1.0201077, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.8510935, + 1.0004079, + 1.0 + ], + [ + -0.13690601, + 1.0438355, + 1.0 + ], + [ + 0.5490022, + 1.0381058, + 1.0 + ], + [ + 1.4847147, + 1.0113307, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2298164, + 0.98695815, + 1.0 + ], + [ + -0.5338396, + 1.008648, + 1.0 + ], + [ + -0.1529312, + 1.0192995, + 1.0 + ], + [ + 1.3007274, + 0.98695505, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.9663947, + 0.9861699, + 1.0 + ], + [ + 0.3281501, + 0.9828017, + 1.0 + ], + [ + -0.9316485, + 0.9753089, + 1.0 + ], + [ + -0.45177975, + 0.9492595, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.8109272, + 0.98814505, + 1.0 + ], + [ + -0.06094338, + 0.99206847, + 1.0 + ], + [ + -1.5175383, + 0.982531, + 1.0 + ], + [ + -1.9885272, + 0.954517, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.5236363, + 0.9898073, + 1.0 + ], + [ + -0.5354513, + 1.0057786, + 1.0 + ], + [ + -2.319607, + 1.0002798, + 1.0 + ], + [ + -3.4267511, + 0.97033256, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.6655283, + 1.0100801, + 1.0 + ], + [ + -1.0105478, + 1.0113044, + 1.0 + ], + [ + -3.3712995, + 0.9907962, + 1.0 + ], + [ + -5.3614125, + 0.957974, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.55430204, + 1.0288806, + 1.0 + ], + [ + -2.2823086, + 1.0114845, + 1.0 + ], + [ + -4.29412, + 0.9850471, + 1.0 + ], + [ + -6.70813, + 0.94654834, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.28653958, + 1.029249, + 1.0 + ], + [ + -2.9026222, + 1.0021783, + 1.0 + ], + [ + -4.3481326, + 0.98601437, + 1.0 + ], + [ + -6.742446, + 0.94504565, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.840411, + 1.0304451, + 1.0 + ], + [ + -1.7543678, + 1.0166836, + 1.0 + ], + [ + -4.263933, + 0.98349094, + 1.0 + ], + [ + -7.782672, + 0.9144162, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.9039959, + 1.0615926, + 1.0 + ], + [ + -0.6004897, + 1.0442605, + 1.0 + ], + [ + -3.2590501, + 0.9852762, + 1.0 + ], + [ + -6.0829635, + 0.92023736, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.113609, + 1.1155086, + 1.0 + ], + [ + 0.7005239, + 1.0668454, + 1.0 + ], + [ + -1.9628628, + 0.9854875, + 1.0 + ], + [ + -5.3814754, + 0.90371615, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.0877285, + 1.1595732, + 1.0 + ], + [ + 1.9560493, + 1.0802444, + 1.0 + ], + [ + -0.77529097, + 0.9800677, + 1.0 + ], + [ + -5.565922, + 0.8722543, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.288913, + 1.2051771, + 1.0 + ], + [ + 2.74089, + 1.1004237, + 1.0 + ], + [ + 0.012938409, + 0.9697918, + 1.0 + ], + [ + -5.393885, + 0.8341304, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.8245485, + 1.2282706, + 1.0 + ], + [ + 2.9895375, + 1.1039253, + 1.0 + ], + [ + 1.2145354, + 0.95314676, + 1.0 + ], + [ + -2.3480604, + 0.8217049, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0588024, + 1.2314723, + 1.0 + ], + [ + 2.7796125, + 1.0972726, + 1.0 + ], + [ + 2.4223437, + 0.9613367, + 1.0 + ], + [ + 1.6759906, + 0.8447568, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.8357061, + 1.210052, + 1.0 + ], + [ + 2.2408845, + 1.0848253, + 1.0 + ], + [ + 2.808005, + 0.9623734, + 1.0 + ], + [ + 3.4482584, + 0.8608864, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.69474894, + 1.1947756, + 1.0 + ], + [ + 1.6912163, + 1.0640128, + 1.0 + ], + [ + 3.070553, + 0.93303436, + 1.0 + ], + [ + 5.026965, + 0.83831453, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.02924209, + 1.1594467, + 1.0 + ], + [ + 1.437672, + 1.0297697, + 1.0 + ], + [ + 3.5909321, + 0.90887827, + 1.0 + ], + [ + 5.7714977, + 0.8360717, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.73282367, + 1.1322978, + 1.0 + ], + [ + 1.0370998, + 1.0152173, + 1.0 + ], + [ + 2.9553826, + 0.93621993, + 1.0 + ], + [ + 4.670117, + 0.86914015, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.619349, + 1.1252851, + 1.0 + ], + [ + 0.15239729, + 1.0244952, + 1.0 + ], + [ + 1.6071638, + 0.960652, + 1.0 + ], + [ + 2.7668586, + 0.8957443, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.4471934, + 1.1201713, + 1.0 + ], + [ + -0.9853024, + 1.0402389, + 1.0 + ], + [ + -0.10679894, + 1.0003766, + 1.0 + ], + [ + 0.38707855, + 0.9576168, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.6268017, + 1.1333272, + 1.0 + ], + [ + -2.5076745, + 1.071064, + 1.0 + ], + [ + -1.8380454, + 1.0375947, + 1.0 + ], + [ + -0.49257776, + 1.0004728, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.8864617, + 1.1463132, + 1.0 + ], + [ + -3.6842506, + 1.0934086, + 1.0 + ], + [ + -2.3747447, + 1.0497583, + 1.0 + ], + [ + -0.121673696, + 1.0016263, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.1770673, + 1.1691642, + 1.0 + ], + [ + -4.8939047, + 1.1140888, + 1.0 + ], + [ + -2.1122801, + 1.0347022, + 1.0 + ], + [ + -0.2781079, + 0.9971531, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.824204, + 1.1899806, + 1.0 + ], + [ + -6.7190065, + 1.1285441, + 1.0 + ], + [ + -5.1226296, + 0.9854947, + 1.0 + ], + [ + -4.016408, + 0.9494332, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.926484, + 1.1482611, + 1.0 + ], + [ + -7.6249037, + 1.0958742, + 1.0 + ], + [ + -5.6792645, + 0.9760871, + 1.0 + ], + [ + -4.0723734, + 0.94808024, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.670957, + 1.1153084, + 1.0 + ], + [ + -8.038458, + 1.0422579, + 1.0 + ], + [ + -5.8509502, + 0.9850574, + 1.0 + ], + [ + -0.8264564, + 0.9961184, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -10.342434, + 1.0856272, + 1.0 + ], + [ + -9.040556, + 1.0213751, + 1.0 + ], + [ + -7.0447855, + 0.9760523, + 1.0 + ], + [ + -0.8563134, + 0.9967798, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.15017, + 1.0738698, + 1.0 + ], + [ + -8.293659, + 1.0015079, + 1.0 + ], + [ + -3.465691, + 0.95468867, + 1.0 + ], + [ + -0.26872262, + 0.996449, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.361571, + 1.0671077, + 1.0 + ], + [ + -8.069062, + 0.9878739, + 1.0 + ], + [ + -1.8393602, + 0.9616174, + 1.0 + ], + [ + -0.062451784, + 0.99806386, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.813561, + 1.0733019, + 1.0 + ], + [ + -6.277449, + 0.9938862, + 1.0 + ], + [ + -0.91688836, + 0.98827964, + 1.0 + ], + [ + -0.015721286, + 0.99975455, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.033608, + 1.0607992, + 1.0 + ], + [ + -5.8924174, + 1.0059938, + 1.0 + ], + [ + -0.4630321, + 0.99918526, + 1.0 + ], + [ + -0.0028961068, + 0.9999799, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.24634, + 1.0676687, + 1.0 + ], + [ + -8.926855, + 1.0089554, + 1.0 + ], + [ + -1.2582607, + 0.998966, + 1.0 + ], + [ + -0.01142765, + 0.9999851, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.128018, + 1.0785146, + 1.0 + ], + [ + -8.119614, + 1.0094862, + 1.0 + ], + [ + -1.2649353, + 0.99898714, + 1.0 + ], + [ + -0.015170822, + 0.9999889, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.451852, + 1.0737165, + 1.0 + ], + [ + -6.9139895, + 1.0113754, + 1.0 + ], + [ + -1.088725, + 1.0001466, + 1.0 + ], + [ + -0.013777146, + 1.0000031, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.380149, + 1.0823865, + 1.0 + ], + [ + -6.0622687, + 1.0224757, + 1.0 + ], + [ + -0.49693757, + 1.0009257, + 1.0 + ], + [ + -0.004322603, + 1.0000051, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.628408, + 1.0830122, + 1.0 + ], + [ + -7.603063, + 1.0293987, + 1.0 + ], + [ + -0.7915703, + 1.0023602, + 1.0 + ], + [ + -0.005485728, + 1.0000141, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.070874, + 1.025278, + 1.0 + ], + [ + -8.004763, + 0.9920342, + 1.0 + ], + [ + -0.6927761, + 0.99896514, + 1.0 + ], + [ + -0.0051751113, + 0.99998105, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.791659, + 0.9914543, + 1.0 + ], + [ + -7.6757627, + 0.9670585, + 1.0 + ], + [ + -0.94827396, + 0.98121995, + 1.0 + ], + [ + -0.042875327, + 0.99850494, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.504122, + 0.918028, + 1.0 + ], + [ + -9.548977, + 0.89464694, + 1.0 + ], + [ + -2.6915827, + 0.9125134, + 1.0 + ], + [ + -0.46877646, + 0.9763951, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.834205, + 0.86167675, + 1.0 + ], + [ + -11.029793, + 0.8641353, + 1.0 + ], + [ + -2.304663, + 0.940338, + 1.0 + ], + [ + -0.22860166, + 0.9885806, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.163353, + 0.80446744, + 1.0 + ], + [ + -10.703714, + 0.82697487, + 1.0 + ], + [ + -1.8142037, + 0.948325, + 1.0 + ], + [ + -0.0477379, + 0.998123, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.573051, + 0.76530474, + 1.0 + ], + [ + -10.532188, + 0.808058, + 1.0 + ], + [ + -1.7215453, + 0.94441307, + 1.0 + ], + [ + -0.0065259486, + 0.99902344, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.712678, + 0.78053474, + 1.0 + ], + [ + -9.12928, + 0.8284735, + 1.0 + ], + [ + -3.0647676, + 0.9377219, + 1.0 + ], + [ + -0.02522077, + 0.9987442, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.4553695, + 0.88699067, + 1.0 + ], + [ + -4.486537, + 0.9664551, + 1.0 + ], + [ + -1.3492911, + 1.0004994, + 1.0 + ], + [ + 0.20210887, + 0.98605, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.5626588, + 1.0187794, + 1.0 + ], + [ + -0.16849272, + 1.0859944, + 1.0 + ], + [ + 1.1092399, + 1.1023259, + 1.0 + ], + [ + 1.3777262, + 1.0548018, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.040617283, + 1.066993, + 1.0 + ], + [ + 1.53646, + 1.1038263, + 1.0 + ], + [ + 2.3726995, + 1.106449, + 1.0 + ], + [ + 2.8319159, + 1.0958794, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8091955, + 1.042359, + 1.0 + ], + [ + 2.302053, + 1.0939299, + 1.0 + ], + [ + 3.1255443, + 1.0935813, + 1.0 + ], + [ + 3.7047086, + 1.0906761, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4341065, + 0.9560042, + 1.0 + ], + [ + 2.3559563, + 1.0608075, + 1.0 + ], + [ + 3.3579242, + 1.0881857, + 1.0 + ], + [ + 3.2111607, + 1.0706342, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0547562, + 0.8983519, + 1.0 + ], + [ + 3.383354, + 0.9991967, + 1.0 + ], + [ + 1.8556306, + 1.0425386, + 1.0 + ], + [ + 0.0885949, + 1.0138919, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7682338, + 0.8603291, + 1.0 + ], + [ + 3.0132825, + 0.9588983, + 1.0 + ], + [ + 1.9995759, + 1.0111612, + 1.0 + ], + [ + 0.47482064, + 1.006286, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.6232177, + 0.8233142, + 1.0 + ], + [ + 2.0865932, + 0.9309424, + 1.0 + ], + [ + 2.7574844, + 0.97783035, + 1.0 + ], + [ + 0.95219344, + 0.99299604, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.6943194, + 0.79197764, + 1.0 + ], + [ + 1.8879877, + 0.920102, + 1.0 + ], + [ + 1.2647737, + 0.9986511, + 1.0 + ], + [ + 0.3897333, + 1.0005867, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7574468, + 0.78670424, + 1.0 + ], + [ + 2.1780686, + 0.9121051, + 1.0 + ], + [ + -0.5525949, + 1.0213124, + 1.0 + ], + [ + -0.2346502, + 1.0064204, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.4999485, + 0.7841847, + 1.0 + ], + [ + 1.9435306, + 0.9184122, + 1.0 + ], + [ + -1.5851839, + 1.0305399, + 1.0 + ], + [ + -0.36774108, + 1.0069425, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.909163, + 0.80230415, + 1.0 + ], + [ + 2.1358707, + 0.935365, + 1.0 + ], + [ + -0.84193116, + 1.0221872, + 1.0 + ], + [ + -0.14351588, + 1.0037925, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.5533886, + 0.8221586, + 1.0 + ], + [ + 3.3628576, + 0.95415354, + 1.0 + ], + [ + 0.8394116, + 1.0065193, + 1.0 + ], + [ + 0.021697784, + 1.000693, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 8.034471, + 0.84136575, + 1.0 + ], + [ + 4.6422033, + 0.9475814, + 1.0 + ], + [ + 1.4912386, + 0.99571365, + 1.0 + ], + [ + 0.018330399, + 1.0000721, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.851116, + 0.9006105, + 1.0 + ], + [ + 5.2049785, + 0.9920339, + 1.0 + ], + [ + 1.2569566, + 0.99717367, + 1.0 + ], + [ + 0.0052652257, + 0.9997678, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.6648865, + 0.9650344, + 1.0 + ], + [ + 4.729117, + 1.0339372, + 1.0 + ], + [ + 2.091848, + 0.99583614, + 1.0 + ], + [ + 0.10322704, + 0.99907774, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.6680355, + 0.9877355, + 1.0 + ], + [ + 3.6761901, + 1.0451121, + 1.0 + ], + [ + -0.04351923, + 1.0153469, + 1.0 + ], + [ + -0.03283479, + 1.0004294, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.463548, + 0.96174765, + 1.0 + ], + [ + 2.6405559, + 1.0113807, + 1.0 + ], + [ + -0.0145218745, + 1.0030446, + 1.0 + ], + [ + 0.02793165, + 1.0006396, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.7405667, + 0.97143346, + 1.0 + ], + [ + 1.3641241, + 1.0326787, + 1.0 + ], + [ + -3.4862273, + 1.0015736, + 1.0 + ], + [ + -0.47250292, + 0.99943143, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 8.19507, + 0.96321476, + 1.0 + ], + [ + 3.4350495, + 1.058396, + 1.0 + ], + [ + -3.5258145, + 1.0094136, + 1.0 + ], + [ + -1.0841179, + 0.9979366, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.8372226, + 0.95261025, + 1.0 + ], + [ + 4.014715, + 1.0542111, + 1.0 + ], + [ + 0.26240677, + 1.0264678, + 1.0 + ], + [ + -0.21519896, + 1.0007783, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.1908545, + 0.95756376, + 1.0 + ], + [ + 1.8382027, + 1.0398521, + 1.0 + ], + [ + 0.32465455, + 1.0176383, + 1.0 + ], + [ + -0.07286978, + 0.99991214, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.5853324, + 0.9502199, + 1.0 + ], + [ + 0.9021832, + 1.0475446, + 1.0 + ], + [ + -0.38347927, + 1.0296501, + 1.0 + ], + [ + -0.10875285, + 1.0017142, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.5904784, + 0.95001745, + 1.0 + ], + [ + 0.3187001, + 1.0552145, + 1.0 + ], + [ + -0.065457314, + 1.0441794, + 1.0 + ], + [ + 0.1682706, + 1.0044458, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.727416, + 0.9608402, + 1.0 + ], + [ + -0.4404374, + 1.0511845, + 1.0 + ], + [ + 0.13549781, + 1.0374392, + 1.0 + ], + [ + 1.0298581, + 1.0050887, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.1869535, + 0.96325994, + 1.0 + ], + [ + 0.21250498, + 1.0488186, + 1.0 + ], + [ + 0.5558834, + 1.0353485, + 1.0 + ], + [ + 1.3571175, + 1.0007496, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.2386184, + 0.97497666, + 1.0 + ], + [ + 0.37397635, + 1.0394269, + 1.0 + ], + [ + 0.7773281, + 1.034625, + 1.0 + ], + [ + 1.4875422, + 1.0045793, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.65055, + 0.98630524, + 1.0 + ], + [ + 0.3505162, + 1.0497316, + 1.0 + ], + [ + 1.0327646, + 1.0434405, + 1.0 + ], + [ + 1.5126252, + 1.0201807, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.1305985, + 0.95614517, + 1.0 + ], + [ + -0.6686687, + 0.96170896, + 1.0 + ], + [ + 0.31405145, + 0.94526744, + 1.0 + ], + [ + 0.7571128, + 0.93466496, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.7789507, + 0.96007, + 1.0 + ], + [ + 0.025442632, + 0.9322739, + 1.0 + ], + [ + 1.002043, + 0.92365426, + 1.0 + ], + [ + 1.4359783, + 0.9207567, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.5217903, + 0.9649529, + 1.0 + ], + [ + 1.3698361, + 0.93013364, + 1.0 + ], + [ + 0.81097263, + 0.8966511, + 1.0 + ], + [ + 1.3134359, + 0.9056901, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.4688148, + 0.94516605, + 1.0 + ], + [ + 1.1258384, + 0.9285592, + 1.0 + ], + [ + -0.03493635, + 0.9379082, + 1.0 + ], + [ + -0.34919712, + 0.92182875, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0855563, + 0.9416833, + 1.0 + ], + [ + 0.61865765, + 0.95953137, + 1.0 + ], + [ + -0.53801084, + 0.98488677, + 1.0 + ], + [ + -1.3929236, + 0.9545244, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.280524, + 0.9401836, + 1.0 + ], + [ + 0.5206633, + 0.91849095, + 1.0 + ], + [ + -0.5855471, + 0.9109686, + 1.0 + ], + [ + -1.857449, + 0.9108761, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.1066036, + 0.91177595, + 1.0 + ], + [ + -0.2379211, + 0.90215147, + 1.0 + ], + [ + -0.662678, + 0.8674564, + 1.0 + ], + [ + -1.6655282, + 0.8560494, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.3806509, + 0.91369975, + 1.0 + ], + [ + -1.1977674, + 0.9291132, + 1.0 + ], + [ + -2.1955984, + 0.9157609, + 1.0 + ], + [ + -3.2753534, + 0.8795514, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.27487, + 0.8657369, + 1.0 + ], + [ + 0.84525156, + 0.8752064, + 1.0 + ], + [ + -1.1533172, + 0.88577944, + 1.0 + ], + [ + -4.5261264, + 0.8575033, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.6447818, + 0.8502878, + 1.0 + ], + [ + 1.9611719, + 0.87647694, + 1.0 + ], + [ + -0.25773066, + 0.88421917, + 1.0 + ], + [ + -2.0909984, + 0.84323335, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.3915353, + 0.95410204, + 1.0 + ], + [ + 1.9980977, + 0.9531656, + 1.0 + ], + [ + 0.5931035, + 0.88896406, + 1.0 + ], + [ + -1.3361316, + 0.8371896, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.3264365, + 1.0299196, + 1.0 + ], + [ + 2.5364761, + 0.97348475, + 1.0 + ], + [ + 1.3221674, + 0.88718134, + 1.0 + ], + [ + -0.9853643, + 0.8285287, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.1308208, + 1.0718769, + 1.0 + ], + [ + 2.790149, + 0.9912555, + 1.0 + ], + [ + 1.4749019, + 0.8944228, + 1.0 + ], + [ + -1.1555754, + 0.8163407, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2958386, + 1.088511, + 1.0 + ], + [ + 2.6916971, + 0.9975709, + 1.0 + ], + [ + 1.9740455, + 0.88601756, + 1.0 + ], + [ + 0.36857194, + 0.80993265, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.5474374, + 1.0788504, + 1.0 + ], + [ + 2.225414, + 0.97936237, + 1.0 + ], + [ + 1.8757105, + 0.88728917, + 1.0 + ], + [ + 1.5990429, + 0.8291341, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.2438161, + 1.036749, + 1.0 + ], + [ + 1.3613421, + 0.9619165, + 1.0 + ], + [ + 1.4527313, + 0.8871177, + 1.0 + ], + [ + 2.0332718, + 0.8303382, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.09551922, + 1.0317862, + 1.0 + ], + [ + 0.43391225, + 0.9460805, + 1.0 + ], + [ + 1.4488848, + 0.8737923, + 1.0 + ], + [ + 3.4753993, + 0.81625134, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.7019963, + 1.0077614, + 1.0 + ], + [ + -0.051761873, + 0.9282602, + 1.0 + ], + [ + 1.9012142, + 0.8732041, + 1.0 + ], + [ + 4.1987333, + 0.82554394, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.6378045, + 1.0135831, + 1.0 + ], + [ + -0.38506448, + 0.9445609, + 1.0 + ], + [ + 1.5109704, + 0.91002667, + 1.0 + ], + [ + 3.479174, + 0.8619353, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.4618201, + 1.033089, + 1.0 + ], + [ + -1.2937772, + 0.97869325, + 1.0 + ], + [ + 0.10574721, + 0.9512708, + 1.0 + ], + [ + 1.488456, + 0.92858106, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.1203666, + 1.0350168, + 1.0 + ], + [ + -2.2538068, + 0.99723995, + 1.0 + ], + [ + -1.1310667, + 0.97431004, + 1.0 + ], + [ + 0.084608465, + 0.98049074, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.3735642, + 1.0520235, + 1.0 + ], + [ + -3.6140828, + 1.0261108, + 1.0 + ], + [ + -1.7979896, + 1.003871, + 1.0 + ], + [ + -0.05716326, + 0.9986102, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -5.2478585, + 1.0614483, + 1.0 + ], + [ + -4.203233, + 1.0365448, + 1.0 + ], + [ + -1.1785761, + 1.0052958, + 1.0 + ], + [ + -0.012518613, + 0.999905, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -5.7618976, + 1.0709513, + 1.0 + ], + [ + -4.2346697, + 1.041181, + 1.0 + ], + [ + -0.55711687, + 1.0027059, + 1.0 + ], + [ + -0.012013823, + 0.99982893, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.164432, + 1.0977043, + 1.0 + ], + [ + -4.544374, + 1.0551589, + 1.0 + ], + [ + -0.9486156, + 1.0006344, + 1.0 + ], + [ + -0.2880987, + 0.99641603, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.89083, + 1.0878369, + 1.0 + ], + [ + -5.120464, + 1.0464156, + 1.0 + ], + [ + -0.8068461, + 0.99902976, + 1.0 + ], + [ + -0.2889861, + 0.9960283, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.618337, + 1.0371728, + 1.0 + ], + [ + -5.1906323, + 1.007005, + 1.0 + ], + [ + -2.135625, + 0.9941443, + 1.0 + ], + [ + -0.21111596, + 0.99932176, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.985677, + 1.0003744, + 1.0 + ], + [ + -5.3432465, + 0.98975044, + 1.0 + ], + [ + -2.6925411, + 0.99021024, + 1.0 + ], + [ + -0.2637423, + 0.99901223, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.557486, + 1.0012228, + 1.0 + ], + [ + -5.126186, + 0.97562605, + 1.0 + ], + [ + -1.487278, + 0.9767831, + 1.0 + ], + [ + -0.09417072, + 0.99855137, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.93245, + 0.9848428, + 1.0 + ], + [ + -5.005292, + 0.95344836, + 1.0 + ], + [ + -1.0024476, + 0.97530013, + 1.0 + ], + [ + -0.03428691, + 0.9989181, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -10.067035, + 0.99813294, + 1.0 + ], + [ + -3.760256, + 0.9666209, + 1.0 + ], + [ + -0.43569535, + 0.9912011, + 1.0 + ], + [ + -0.007490502, + 0.99986094, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.267269, + 0.975714, + 1.0 + ], + [ + -3.3425367, + 0.97395366, + 1.0 + ], + [ + -0.16193047, + 0.99658626, + 1.0 + ], + [ + -0.00091553305, + 0.9999671, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.993388, + 1.0000288, + 1.0 + ], + [ + -4.422481, + 0.985607, + 1.0 + ], + [ + -0.32398883, + 0.9973332, + 1.0 + ], + [ + -0.0023061063, + 0.99997354, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.084186, + 0.9982832, + 1.0 + ], + [ + -2.7218752, + 0.9938026, + 1.0 + ], + [ + -0.17255202, + 0.9993347, + 1.0 + ], + [ + -0.0015168872, + 0.99999547, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.26013, + 0.9777609, + 1.0 + ], + [ + -2.3467531, + 0.98704755, + 1.0 + ], + [ + -0.09092708, + 0.9993146, + 1.0 + ], + [ + -0.00067042705, + 0.9999976, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.507622, + 0.9864571, + 1.0 + ], + [ + -1.6202773, + 0.9965017, + 1.0 + ], + [ + -0.03161555, + 0.999892, + 1.0 + ], + [ + -0.00015287292, + 0.9999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.677804, + 0.9922909, + 1.0 + ], + [ + -1.5864677, + 1.0017024, + 1.0 + ], + [ + -0.029533267, + 1.0000547, + 1.0 + ], + [ + -0.00012411954, + 1.0000002, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.914085, + 0.95310205, + 1.0 + ], + [ + -2.334698, + 0.99508375, + 1.0 + ], + [ + -0.04895491, + 0.99995136, + 1.0 + ], + [ + -0.00017408784, + 0.9999993, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.237178, + 0.8746589, + 1.0 + ], + [ + -3.9431214, + 0.96841735, + 1.0 + ], + [ + -0.13730685, + 0.99794483, + 1.0 + ], + [ + -0.0053191665, + 0.999769, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.975474, + 0.80612254, + 1.0 + ], + [ + -5.441822, + 0.93426883, + 1.0 + ], + [ + -0.5847607, + 0.97942346, + 1.0 + ], + [ + -0.0973578, + 0.99491835, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -16.96701, + 0.7174082, + 1.0 + ], + [ + -7.501978, + 0.8796432, + 1.0 + ], + [ + -0.55444115, + 0.9846425, + 1.0 + ], + [ + -0.0493976, + 0.9975046, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -19.691572, + 0.6349826, + 1.0 + ], + [ + -11.398152, + 0.7708063, + 1.0 + ], + [ + -0.78708935, + 0.97114944, + 1.0 + ], + [ + -0.013223002, + 0.99905497, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -18.446426, + 0.61982644, + 1.0 + ], + [ + -14.164779, + 0.6866288, + 1.0 + ], + [ + -1.6122752, + 0.95558673, + 1.0 + ], + [ + -0.004578955, + 0.9995476, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.488046, + 0.67327017, + 1.0 + ], + [ + -11.041826, + 0.6985784, + 1.0 + ], + [ + -3.797718, + 0.8836202, + 1.0 + ], + [ + -0.021354746, + 0.9991143, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.215271, + 0.79168403, + 1.0 + ], + [ + -5.9288893, + 0.82115835, + 1.0 + ], + [ + -3.627646, + 0.8675918, + 1.0 + ], + [ + -0.10416387, + 0.9819024, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.4996793, + 0.90832794, + 1.0 + ], + [ + -1.8338926, + 0.95480883, + 1.0 + ], + [ + -1.2572361, + 0.96503127, + 1.0 + ], + [ + -0.7744646, + 0.9684802, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.1851404, + 0.9754471, + 1.0 + ], + [ + -0.38070896, + 0.9967761, + 1.0 + ], + [ + -0.20595744, + 0.99183595, + 1.0 + ], + [ + -0.04291819, + 0.991432, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.24601784, + 0.9362298, + 1.0 + ], + [ + 0.4131334, + 1.0015087, + 1.0 + ], + [ + 0.6097395, + 1.0106862, + 1.0 + ], + [ + 0.80979943, + 1.0157323, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.15171635, + 0.7862158, + 1.0 + ], + [ + 0.88354856, + 0.9524625, + 1.0 + ], + [ + 1.2969238, + 1.0249856, + 1.0 + ], + [ + 1.3480076, + 1.0304265, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.2697306, + 0.74219435, + 1.0 + ], + [ + 2.2566214, + 0.8504037, + 1.0 + ], + [ + 0.43116477, + 0.98642737, + 1.0 + ], + [ + -0.62197345, + 0.9856124, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.9409392, + 0.7197661, + 1.0 + ], + [ + 2.657014, + 0.82564926, + 1.0 + ], + [ + 0.75268286, + 0.9767613, + 1.0 + ], + [ + 0.055744026, + 0.9941756, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.8314111, + 0.7361832, + 1.0 + ], + [ + 2.2688785, + 0.83848625, + 1.0 + ], + [ + 0.86895645, + 0.979231, + 1.0 + ], + [ + 0.0529236, + 1.0007346, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.6000655, + 0.7524115, + 1.0 + ], + [ + 2.0968463, + 0.888389, + 1.0 + ], + [ + 0.18316315, + 0.9902795, + 1.0 + ], + [ + -0.048968393, + 0.99920696, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.5598903, + 0.763862, + 1.0 + ], + [ + 1.1714306, + 0.8932834, + 1.0 + ], + [ + -0.57951224, + 0.985895, + 1.0 + ], + [ + -0.13511501, + 0.99782085, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.1605225, + 0.7730435, + 1.0 + ], + [ + 1.3596042, + 0.9008872, + 1.0 + ], + [ + -0.47168514, + 0.99376947, + 1.0 + ], + [ + -0.07464302, + 1.000353, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.752158, + 0.79429775, + 1.0 + ], + [ + 0.96804696, + 0.9385464, + 1.0 + ], + [ + -1.3032031, + 0.99664253, + 1.0 + ], + [ + -0.2467647, + 0.99934804, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.692927, + 0.817162, + 1.0 + ], + [ + 1.2399592, + 0.9601324, + 1.0 + ], + [ + -0.2092135, + 0.99869674, + 1.0 + ], + [ + -0.04914082, + 0.9998154, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.3077126, + 0.8646981, + 1.0 + ], + [ + 1.169301, + 0.9719851, + 1.0 + ], + [ + -0.14287929, + 0.9983894, + 1.0 + ], + [ + -0.070898615, + 0.9997087, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.741663, + 0.89205533, + 1.0 + ], + [ + 0.64960855, + 0.9830752, + 1.0 + ], + [ + -0.11191467, + 0.9987406, + 1.0 + ], + [ + -0.047313914, + 0.99977046, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.5162182, + 0.94403803, + 1.0 + ], + [ + 0.07143193, + 0.9925508, + 1.0 + ], + [ + -0.72076577, + 0.9948075, + 1.0 + ], + [ + -0.1948217, + 0.9987798, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.585983, + 0.96052504, + 1.0 + ], + [ + 1.0876106, + 0.9904767, + 1.0 + ], + [ + -0.27299738, + 0.99780345, + 1.0 + ], + [ + -0.080156155, + 0.99953175, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.779256, + 0.9310572, + 1.0 + ], + [ + 0.7266878, + 0.986474, + 1.0 + ], + [ + 0.035173222, + 0.99929446, + 1.0 + ], + [ + 0.0016433608, + 1.0000198, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.0365615, + 0.94872975, + 1.0 + ], + [ + 0.30465418, + 0.9817023, + 1.0 + ], + [ + -0.34721652, + 0.99478245, + 1.0 + ], + [ + -0.061744135, + 0.99972767, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.8961987, + 0.92921335, + 1.0 + ], + [ + 0.23940657, + 0.9766385, + 1.0 + ], + [ + -1.319209, + 0.99436516, + 1.0 + ], + [ + -0.2552591, + 0.9993648, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.820354, + 0.9000567, + 1.0 + ], + [ + 0.93661356, + 0.9792745, + 1.0 + ], + [ + -1.2972772, + 1.0013816, + 1.0 + ], + [ + -0.2005057, + 1.0001276, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.8547032, + 0.8771825, + 1.0 + ], + [ + -0.40298477, + 0.92672235, + 1.0 + ], + [ + -0.76510406, + 0.983101, + 1.0 + ], + [ + -0.10773284, + 0.9988855, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.1437955, + 0.87208176, + 1.0 + ], + [ + -0.35798803, + 0.9345046, + 1.0 + ], + [ + -0.6719991, + 0.9889946, + 1.0 + ], + [ + -0.12287665, + 0.9989714, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.7768846, + 0.90311426, + 1.0 + ], + [ + -0.6762936, + 0.95233446, + 1.0 + ], + [ + -1.8836145, + 0.9920913, + 1.0 + ], + [ + -0.860416, + 0.9946867, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.8961434, + 0.8824976, + 1.0 + ], + [ + -0.33391425, + 0.92908406, + 1.0 + ], + [ + -0.3221192, + 0.9918024, + 1.0 + ], + [ + -0.09044509, + 0.9966171, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.8632536, + 0.90186435, + 1.0 + ], + [ + 0.58749425, + 0.9437153, + 1.0 + ], + [ + 0.48553416, + 0.98712, + 1.0 + ], + [ + 0.19696854, + 0.99386424, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.8013225, + 0.9050866, + 1.0 + ], + [ + -0.49256903, + 0.9264555, + 1.0 + ], + [ + -1.7974619, + 0.9501046, + 1.0 + ], + [ + -1.5920177, + 0.97445244, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.4498076, + 0.92861384, + 1.0 + ], + [ + -0.7343135, + 0.9649644, + 1.0 + ], + [ + -1.2369624, + 0.9541305, + 1.0 + ], + [ + -1.1794292, + 0.9540083, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.9042714, + 0.9180204, + 1.0 + ], + [ + -2.219778, + 0.86444235, + 1.0 + ], + [ + -2.4816806, + 0.823798, + 1.0 + ], + [ + -1.5569929, + 0.8375031, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.83755153, + 0.91707826, + 1.0 + ], + [ + 0.22283489, + 0.86605746, + 1.0 + ], + [ + 0.4647223, + 0.8345799, + 1.0 + ], + [ + 0.72768146, + 0.84671277, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.523536, + 0.9087665, + 1.0 + ], + [ + 1.2768013, + 0.85424995, + 1.0 + ], + [ + 2.1177902, + 0.8432673, + 1.0 + ], + [ + 2.3680518, + 0.8740304, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2746108, + 0.81840515, + 1.0 + ], + [ + 2.7676985, + 0.7900528, + 1.0 + ], + [ + 1.9201541, + 0.8856915, + 1.0 + ], + [ + 1.8866074, + 0.9185119, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.6828327, + 0.8136951, + 1.0 + ], + [ + 2.175628, + 0.84009886, + 1.0 + ], + [ + 2.0939028, + 0.8847502, + 1.0 + ], + [ + 2.5929403, + 0.8947538, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.768817, + 0.7967659, + 1.0 + ], + [ + 3.2244751, + 0.7584971, + 1.0 + ], + [ + 3.6050847, + 0.7831417, + 1.0 + ], + [ + 4.268032, + 0.8045191, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.3562844, + 0.7890957, + 1.0 + ], + [ + 3.7188308, + 0.7199401, + 1.0 + ], + [ + 5.9207273, + 0.6976366, + 1.0 + ], + [ + 7.6120353, + 0.70215327, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0832772, + 0.7600011, + 1.0 + ], + [ + 2.2862017, + 0.76166797, + 1.0 + ], + [ + 3.6557348, + 0.74157053, + 1.0 + ], + [ + 5.3892546, + 0.731114, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.7183886, + 0.6808121, + 1.0 + ], + [ + 3.3387003, + 0.70804834, + 1.0 + ], + [ + 3.3728793, + 0.7371294, + 1.0 + ], + [ + 4.041886, + 0.73437375, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.624325, + 0.6674578, + 1.0 + ], + [ + 4.5378666, + 0.6732078, + 1.0 + ], + [ + 4.398033, + 0.71216196, + 1.0 + ], + [ + 5.5895276, + 0.7182609, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.306699, + 0.747718, + 1.0 + ], + [ + 4.879655, + 0.70281416, + 1.0 + ], + [ + 5.1314225, + 0.7001976, + 1.0 + ], + [ + 5.486214, + 0.72053, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.146373, + 0.78986615, + 1.0 + ], + [ + 3.90747, + 0.7194833, + 1.0 + ], + [ + 4.436473, + 0.7135913, + 1.0 + ], + [ + 3.9732492, + 0.7439657, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.051203, + 0.8220685, + 1.0 + ], + [ + 2.5377185, + 0.73653644, + 1.0 + ], + [ + 3.6690881, + 0.73991066, + 1.0 + ], + [ + 2.8405402, + 0.76611686, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.0629168, + 0.88988215, + 1.0 + ], + [ + 1.5405259, + 0.80136687, + 1.0 + ], + [ + 2.3380709, + 0.755409, + 1.0 + ], + [ + 1.6457901, + 0.78002536, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.9272873, + 0.8686442, + 1.0 + ], + [ + 0.6143116, + 0.7969848, + 1.0 + ], + [ + 0.5216986, + 0.7725941, + 1.0 + ], + [ + 0.27696848, + 0.79700214, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.18347983, + 0.7820147, + 1.0 + ], + [ + -0.2920065, + 0.78137934, + 1.0 + ], + [ + -0.7294265, + 0.8024376, + 1.0 + ], + [ + -0.6821398, + 0.81814516, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.23992704, + 0.7879301, + 1.0 + ], + [ + -0.88038236, + 0.8075125, + 1.0 + ], + [ + -0.9809185, + 0.8284354, + 1.0 + ], + [ + -0.07751103, + 0.8428534, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.80605406, + 0.8354792, + 1.0 + ], + [ + -1.559409, + 0.80948555, + 1.0 + ], + [ + -0.7388032, + 0.83500856, + 1.0 + ], + [ + 0.69942343, + 0.88329875, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.6294653, + 0.85172844, + 1.0 + ], + [ + -1.7645487, + 0.8297184, + 1.0 + ], + [ + -0.7100293, + 0.8840055, + 1.0 + ], + [ + 0.2274858, + 0.9601816, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.2752452, + 0.8992305, + 1.0 + ], + [ + -2.2524478, + 0.9024475, + 1.0 + ], + [ + -0.6900573, + 0.96735644, + 1.0 + ], + [ + -0.01443623, + 0.99743444, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.3342621, + 0.9171061, + 1.0 + ], + [ + -2.169138, + 0.9233468, + 1.0 + ], + [ + -0.38522747, + 0.9775049, + 1.0 + ], + [ + 0.0013864042, + 0.9985635, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.9786882, + 0.9581507, + 1.0 + ], + [ + -1.0984265, + 0.9696264, + 1.0 + ], + [ + -0.10668975, + 0.9950265, + 1.0 + ], + [ + -0.0001844623, + 0.99983454, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.8451796, + 0.9728855, + 1.0 + ], + [ + -0.75453144, + 0.9875004, + 1.0 + ], + [ + -0.03869543, + 0.998888, + 1.0 + ], + [ + -0.00016324317, + 0.99999046, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.4833093, + 0.9710839, + 1.0 + ], + [ + -0.4503776, + 0.99415845, + 1.0 + ], + [ + -0.014969094, + 0.9998039, + 1.0 + ], + [ + -0.00005460353, + 0.99999875, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.155806, + 0.9922136, + 1.0 + ], + [ + -0.24781401, + 0.9983759, + 1.0 + ], + [ + -0.009547606, + 0.9999699, + 1.0 + ], + [ + -0.00066759216, + 0.9999967, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.4516653, + 0.99805427, + 1.0 + ], + [ + -0.20918414, + 0.999779, + 1.0 + ], + [ + -0.006043913, + 0.9999931, + 1.0 + ], + [ + -0.00048581572, + 0.99999654, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.9392998, + 0.98668915, + 1.0 + ], + [ + -0.14349174, + 0.99852246, + 1.0 + ], + [ + -0.01090069, + 0.99995995, + 1.0 + ], + [ + -0.00083713053, + 0.99999756, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.1864663, + 0.9834442, + 1.0 + ], + [ + -0.18628179, + 0.99777365, + 1.0 + ], + [ + -0.014109625, + 0.99992806, + 1.0 + ], + [ + -0.001064983, + 0.9999959, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.0547693, + 0.98770183, + 1.0 + ], + [ + -0.17180885, + 0.9951577, + 1.0 + ], + [ + -0.014593571, + 0.99926215, + 1.0 + ], + [ + -0.00052392326, + 0.99998504, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.3548598, + 0.97179633, + 1.0 + ], + [ + -0.51838243, + 0.95328885, + 1.0 + ], + [ + -0.06852023, + 0.99208575, + 1.0 + ], + [ + -0.00085176574, + 0.99991536, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.3932482, + 0.9842496, + 1.0 + ], + [ + -0.21709155, + 0.9691371, + 1.0 + ], + [ + -0.014957193, + 0.99505883, + 1.0 + ], + [ + -0.00015723599, + 0.99995285, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.95742047, + 0.9800242, + 1.0 + ], + [ + -0.09572666, + 0.9719048, + 1.0 + ], + [ + -0.0004223666, + 0.9954867, + 1.0 + ], + [ + 0.000010266345, + 0.9999555, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8219641, + 0.97176534, + 1.0 + ], + [ + -0.10358055, + 0.9572014, + 1.0 + ], + [ + -0.0028769998, + 0.99260867, + 1.0 + ], + [ + -0.0000138675605, + 0.9999241, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.81078154, + 0.9853913, + 1.0 + ], + [ + -0.07060721, + 0.99701566, + 1.0 + ], + [ + -0.0011506415, + 0.99966466, + 1.0 + ], + [ + -0.0000053977897, + 0.99999666, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8799515, + 0.9880938, + 1.0 + ], + [ + -0.09139433, + 0.99895227, + 1.0 + ], + [ + -0.0011206032, + 0.99998325, + 1.0 + ], + [ + -0.0000023958048, + 0.99999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.203021, + 0.98468393, + 1.0 + ], + [ + -0.09533536, + 0.9993307, + 1.0 + ], + [ + -0.0006138411, + 0.9999971, + 1.0 + ], + [ + -4.9110037e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.98682314, + 0.98741007, + 1.0 + ], + [ + -0.07803176, + 0.99942106, + 1.0 + ], + [ + -0.0005139681, + 0.9999974, + 1.0 + ], + [ + -3.048391e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.0533085, + 0.95174795, + 1.0 + ], + [ + -0.15526569, + 0.9967907, + 1.0 + ], + [ + -0.0011854534, + 0.9999825, + 1.0 + ], + [ + -0.0000012432189, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.3160176, + 0.8893779, + 1.0 + ], + [ + -0.37086096, + 0.99144685, + 1.0 + ], + [ + -0.0031809295, + 0.9999518, + 1.0 + ], + [ + -0.000015129414, + 0.9999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.65379, + 0.80011606, + 1.0 + ], + [ + -0.97619027, + 0.98192537, + 1.0 + ], + [ + -0.011185538, + 0.9998816, + 1.0 + ], + [ + -0.00027599925, + 0.999986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.900999, + 0.6643516, + 1.0 + ], + [ + -2.2590377, + 0.93988913, + 1.0 + ], + [ + -0.021305703, + 0.99952126, + 1.0 + ], + [ + -0.00016454396, + 0.9999922, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -18.50311, + 0.5672969, + 1.0 + ], + [ + -6.324091, + 0.8226143, + 1.0 + ], + [ + -0.09798097, + 0.99709153, + 1.0 + ], + [ + -0.0001432513, + 0.99998575, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -17.64413, + 0.5391097, + 1.0 + ], + [ + -10.467655, + 0.66710144, + 1.0 + ], + [ + -0.2899716, + 0.9890324, + 1.0 + ], + [ + -0.00047773414, + 0.9999807, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.92279, + 0.5472615, + 1.0 + ], + [ + -10.895535, + 0.59090734, + 1.0 + ], + [ + -2.0764418, + 0.9041057, + 1.0 + ], + [ + -0.006843486, + 0.9997131, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.823085, + 0.59954053, + 1.0 + ], + [ + -8.007232, + 0.64031166, + 1.0 + ], + [ + -5.934222, + 0.7216933, + 1.0 + ], + [ + -0.21140227, + 0.98925734, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.0854692, + 0.68398213, + 1.0 + ], + [ + -4.9988737, + 0.7475099, + 1.0 + ], + [ + -4.5387487, + 0.783282, + 1.0 + ], + [ + -3.1189833, + 0.85521823, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.5944576, + 0.7649848, + 1.0 + ], + [ + -4.2297106, + 0.8085376, + 1.0 + ], + [ + -4.0575027, + 0.8316775, + 1.0 + ], + [ + -3.6946223, + 0.857067, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.07587, + 0.76617646, + 1.0 + ], + [ + -3.7157166, + 0.84208333, + 1.0 + ], + [ + -3.0963829, + 0.88380915, + 1.0 + ], + [ + -2.4804156, + 0.9129343, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.87624, + 0.6232938, + 1.0 + ], + [ + -2.6126328, + 0.7971155, + 1.0 + ], + [ + -1.7279347, + 0.92270774, + 1.0 + ], + [ + -1.2290946, + 0.95807064, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.7994735, + 0.57053953, + 1.0 + ], + [ + -0.84233916, + 0.6636242, + 1.0 + ], + [ + -2.5714352, + 0.87382954, + 1.0 + ], + [ + -3.015385, + 0.8965249, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.3739244, + 0.61843336, + 1.0 + ], + [ + 0.4184842, + 0.76101744, + 1.0 + ], + [ + -0.6304034, + 0.94809633, + 1.0 + ], + [ + -0.7494771, + 0.9597989, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.97610384, + 0.7327941, + 1.0 + ], + [ + -0.014398668, + 0.87566924, + 1.0 + ], + [ + -0.052118707, + 0.9924021, + 1.0 + ], + [ + 0.008521295, + 0.99918205, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.5996346, + 0.8233891, + 1.0 + ], + [ + 0.3935372, + 0.9229845, + 1.0 + ], + [ + -0.09391027, + 0.99190533, + 1.0 + ], + [ + -0.03780514, + 0.998876, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.9291275, + 0.9039172, + 1.0 + ], + [ + 0.74847955, + 0.9787055, + 1.0 + ], + [ + -0.16242525, + 0.9931886, + 1.0 + ], + [ + -0.072900325, + 0.9978617, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.3570204, + 0.94836956, + 1.0 + ], + [ + 0.2503319, + 0.99067676, + 1.0 + ], + [ + -0.06907828, + 0.9989688, + 1.0 + ], + [ + -0.017280824, + 0.9998503, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.26804498, + 0.9617563, + 1.0 + ], + [ + -0.30944353, + 0.9933201, + 1.0 + ], + [ + -0.83434904, + 0.9959094, + 1.0 + ], + [ + -0.16132122, + 0.999225, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.45194325, + 0.97071046, + 1.0 + ], + [ + -0.040852666, + 0.99767536, + 1.0 + ], + [ + -0.18153699, + 0.9991148, + 1.0 + ], + [ + -0.03368746, + 0.99983805, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.58231443, + 0.987187, + 1.0 + ], + [ + -0.11279778, + 0.9996504, + 1.0 + ], + [ + -0.007223121, + 0.999977, + 1.0 + ], + [ + -0.0013776413, + 0.9999941, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.27319077, + 0.9664841, + 1.0 + ], + [ + -0.21705957, + 0.9958635, + 1.0 + ], + [ + -0.017606068, + 0.99986523, + 1.0 + ], + [ + -0.0026193615, + 0.9999856, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.21634439, + 0.9787581, + 1.0 + ], + [ + -0.2651225, + 0.9962405, + 1.0 + ], + [ + -0.19124968, + 0.99883384, + 1.0 + ], + [ + -0.042182684, + 0.99975127, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.008343396, + 0.96074563, + 1.0 + ], + [ + -0.06034358, + 0.99365026, + 1.0 + ], + [ + -0.07409725, + 0.99946153, + 1.0 + ], + [ + -0.0167451, + 0.99990094, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.21916604, + 0.97861654, + 1.0 + ], + [ + 0.011656337, + 0.9968962, + 1.0 + ], + [ + 0.000026073394, + 0.9999265, + 1.0 + ], + [ + -0.00011303921, + 0.9999988, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8516588, + 0.983058, + 1.0 + ], + [ + 0.05317016, + 0.9960106, + 1.0 + ], + [ + 0.0021729777, + 0.9996684, + 1.0 + ], + [ + -0.00008834622, + 0.9999944, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.33492264, + 0.97982824, + 1.0 + ], + [ + -0.023441888, + 0.9941379, + 1.0 + ], + [ + -0.012700134, + 0.9997447, + 1.0 + ], + [ + -0.0015705364, + 0.9999959, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.98577476, + 0.9558412, + 1.0 + ], + [ + 0.11146073, + 0.9861902, + 1.0 + ], + [ + -0.039014447, + 0.9995021, + 1.0 + ], + [ + -0.004039938, + 0.9999989, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4198768, + 0.92014015, + 1.0 + ], + [ + 0.017510591, + 0.95569116, + 1.0 + ], + [ + -0.07619311, + 0.99643916, + 1.0 + ], + [ + -0.0026185187, + 0.99994844, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.02523843, + 0.90478045, + 1.0 + ], + [ + -1.0690092, + 0.92566043, + 1.0 + ], + [ + -0.27379805, + 0.99030024, + 1.0 + ], + [ + -0.012234708, + 0.9998211, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.112236924, + 0.94679946, + 1.0 + ], + [ + -0.619657, + 0.96057886, + 1.0 + ], + [ + -0.9257425, + 0.9906025, + 1.0 + ], + [ + -0.29495418, + 0.99792683, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.21400073, + 0.91529566, + 1.0 + ], + [ + -0.7458604, + 0.93239814, + 1.0 + ], + [ + -1.0380262, + 0.98454165, + 1.0 + ], + [ + -0.59706265, + 0.99349016, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.4457225, + 0.91300154, + 1.0 + ], + [ + -1.336635, + 0.9316419, + 1.0 + ], + [ + -2.2979734, + 0.9655022, + 1.0 + ], + [ + -1.344974, + 0.9845655, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.25027204, + 0.9015076, + 1.0 + ], + [ + -3.4001799, + 0.8766961, + 1.0 + ], + [ + -6.167441, + 0.87958527, + 1.0 + ], + [ + -3.0434742, + 0.9592666, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8902801, + 0.87916595, + 1.0 + ], + [ + -3.0949912, + 0.86760527, + 1.0 + ], + [ + -5.8532324, + 0.81513554, + 1.0 + ], + [ + -4.114237, + 0.86317617, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.026975641, + 0.99708, + 1.0 + ], + [ + -0.64516276, + 0.9682286, + 1.0 + ], + [ + -2.8310719, + 0.86257124, + 1.0 + ], + [ + -1.7226968, + 0.90401584, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.082811065, + 0.9958276, + 1.0 + ], + [ + 0.029035931, + 0.98919505, + 1.0 + ], + [ + -0.10363939, + 0.9313849, + 1.0 + ], + [ + 0.010599485, + 0.93757856, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.30115137, + 0.995358, + 1.0 + ], + [ + 0.13033178, + 0.99306947, + 1.0 + ], + [ + 0.6179642, + 0.9668538, + 1.0 + ], + [ + 0.8925493, + 0.96109754, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.46534216, + 0.9799625, + 1.0 + ], + [ + 0.38193727, + 0.97773105, + 1.0 + ], + [ + 0.24684222, + 0.9894079, + 1.0 + ], + [ + 0.37070882, + 0.987069, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.28666112, + 0.9715252, + 1.0 + ], + [ + 0.6129641, + 0.9612684, + 1.0 + ], + [ + 1.5519472, + 0.9354917, + 1.0 + ], + [ + 2.0365298, + 0.9501482, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.5976872, + 0.95618755, + 1.0 + ], + [ + 2.4345472, + 0.8642554, + 1.0 + ], + [ + 4.334619, + 0.8086935, + 1.0 + ], + [ + 4.563015, + 0.845538, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8365076, + 0.92973554, + 1.0 + ], + [ + 4.732671, + 0.7514679, + 1.0 + ], + [ + 8.749074, + 0.67412585, + 1.0 + ], + [ + 9.21404, + 0.70109785, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.420841, + 0.77831614, + 1.0 + ], + [ + 3.1613727, + 0.7190518, + 1.0 + ], + [ + 5.003848, + 0.7066909, + 1.0 + ], + [ + 6.8984137, + 0.7076504, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2887714, + 0.6649921, + 1.0 + ], + [ + 3.2296984, + 0.6861676, + 1.0 + ], + [ + 3.542693, + 0.7290553, + 1.0 + ], + [ + 3.7580287, + 0.7677797, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.8353093, + 0.7206155, + 1.0 + ], + [ + 3.213582, + 0.7210075, + 1.0 + ], + [ + 3.050782, + 0.7906282, + 1.0 + ], + [ + 2.910971, + 0.86843896, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.73087895, + 0.9480793, + 1.0 + ], + [ + 1.3393304, + 0.90520597, + 1.0 + ], + [ + 1.8712325, + 0.8813981, + 1.0 + ], + [ + 1.7535455, + 0.9178119, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.2656817, + 0.9784777, + 1.0 + ], + [ + 0.6957168, + 0.93848366, + 1.0 + ], + [ + 1.0034746, + 0.92618793, + 1.0 + ], + [ + 0.6805362, + 0.95591986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.07101682, + 0.9880643, + 1.0 + ], + [ + 0.3550513, + 0.96534646, + 1.0 + ], + [ + 0.7367733, + 0.952579, + 1.0 + ], + [ + 0.41733772, + 0.96733385, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.021202909, + 0.99511874, + 1.0 + ], + [ + 0.03845068, + 0.98727, + 1.0 + ], + [ + 0.13032648, + 0.9788461, + 1.0 + ], + [ + 0.08247721, + 0.9886881, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.019193187, + 0.99454707, + 1.0 + ], + [ + -0.01639945, + 0.9824369, + 1.0 + ], + [ + -0.034814365, + 0.97299594, + 1.0 + ], + [ + 0.000092838956, + 0.9879655, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0020077226, + 0.98501676, + 1.0 + ], + [ + -0.01932977, + 0.98477453, + 1.0 + ], + [ + -0.08714962, + 0.9838237, + 1.0 + ], + [ + -0.05911569, + 0.99138635, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.028955147, + 0.9820292, + 1.0 + ], + [ + -0.0068421336, + 0.9893989, + 1.0 + ], + [ + -0.066570766, + 0.99078333, + 1.0 + ], + [ + -0.044429842, + 0.995392, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.032754235, + 0.99281675, + 1.0 + ], + [ + -0.024249062, + 0.9940026, + 1.0 + ], + [ + -0.016773215, + 0.99763656, + 1.0 + ], + [ + -0.00368944, + 0.99956965, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.013820456, + 0.9963605, + 1.0 + ], + [ + -0.023061087, + 0.99682546, + 1.0 + ], + [ + -0.006765129, + 0.99912953, + 1.0 + ], + [ + -0.00025614, + 0.99994946, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.03831513, + 0.9965622, + 1.0 + ], + [ + -0.026358912, + 0.9984241, + 1.0 + ], + [ + -0.0031044614, + 0.99983025, + 1.0 + ], + [ + -0.00004853841, + 0.99999666, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.042360652, + 0.99709636, + 1.0 + ], + [ + -0.02741898, + 0.99857044, + 1.0 + ], + [ + -0.0024811549, + 0.99984425, + 1.0 + ], + [ + -0.0000068021504, + 0.99999624, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.015694104, + 0.99947315, + 1.0 + ], + [ + -0.004300875, + 0.9996656, + 1.0 + ], + [ + -0.00022572257, + 0.99996036, + 1.0 + ], + [ + -2.7103656e-8, + 0.9999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.031804975, + 0.9993172, + 1.0 + ], + [ + -0.0036146475, + 0.9998796, + 1.0 + ], + [ + -0.00006266851, + 0.9999905, + 1.0 + ], + [ + -2.0947002e-7, + 0.99999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.03432048, + 0.99876255, + 1.0 + ], + [ + -0.0027313528, + 0.999917, + 1.0 + ], + [ + -0.00002901146, + 0.99999934, + 1.0 + ], + [ + -5.272942e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.010446178, + 0.9998674, + 1.0 + ], + [ + -0.0009102314, + 0.9999884, + 1.0 + ], + [ + -0.000010674661, + 0.9999999, + 1.0 + ], + [ + -6.7580764e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0119908275, + 0.9999285, + 1.0 + ], + [ + -0.00078701315, + 0.9999947, + 1.0 + ], + [ + -0.0000061383644, + 0.99999994, + 1.0 + ], + [ + -3.791176e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.005170042, + 0.99979734, + 1.0 + ], + [ + -0.0004141604, + 0.9999835, + 1.0 + ], + [ + -0.0000034080406, + 0.9999999, + 1.0 + ], + [ + -4.582497e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.00895596, + 0.9998542, + 1.0 + ], + [ + -0.000709496, + 0.99998856, + 1.0 + ], + [ + -0.0000056245453, + 0.99999994, + 1.0 + ], + [ + -5.8625062e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0146880755, + 0.9997208, + 1.0 + ], + [ + -0.0018232282, + 0.9998647, + 1.0 + ], + [ + -0.00015326608, + 0.9999809, + 1.0 + ], + [ + -0.0000014805827, + 0.9999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.022968672, + 0.99913424, + 1.0 + ], + [ + -0.014809832, + 0.99827707, + 1.0 + ], + [ + -0.0020878739, + 0.9997331, + 1.0 + ], + [ + -0.000020184694, + 0.99999744, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.011431702, + 0.99970055, + 1.0 + ], + [ + -0.0039257053, + 0.999323, + 1.0 + ], + [ + -0.00048654518, + 0.9998965, + 1.0 + ], + [ + -0.000004697737, + 0.999999, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0041034487, + 0.99944717, + 1.0 + ], + [ + -0.00006548014, + 0.99873835, + 1.0 + ], + [ + 0.000027589553, + 0.9997964, + 1.0 + ], + [ + 2.7555294e-7, + 0.999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.009381899, + 0.9987364, + 1.0 + ], + [ + -0.00043260615, + 0.9972713, + 1.0 + ], + [ + 0.000017650782, + 0.9995557, + 1.0 + ], + [ + 2.0419672e-7, + 0.99999547, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.008616836, + 0.99971557, + 1.0 + ], + [ + -0.00045231776, + 0.9998694, + 1.0 + ], + [ + -9.984535e-7, + 0.9999807, + 1.0 + ], + [ + 7.740104e-9, + 0.9999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0056563327, + 0.9998474, + 1.0 + ], + [ + -0.00033137825, + 0.99999225, + 1.0 + ], + [ + -0.0000016325387, + 1.0, + 1.0 + ], + [ + -1.0257498e-9, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.014827684, + 0.99960244, + 1.0 + ], + [ + -0.0007938461, + 0.99998146, + 1.0 + ], + [ + -0.00000293517, + 0.99999994, + 1.0 + ], + [ + -8.377253e-10, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.011590649, + 0.9997409, + 1.0 + ], + [ + -0.00065725995, + 0.9999863, + 1.0 + ], + [ + -0.0000025172164, + 0.99999994, + 1.0 + ], + [ + -7.089178e-10, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.11034412, + 0.99728817, + 1.0 + ], + [ + -0.006188652, + 0.9998468, + 1.0 + ], + [ + -0.000022063721, + 0.99999946, + 1.0 + ], + [ + -5.30563e-9, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.11615519, + 0.9962155, + 1.0 + ], + [ + -0.006629864, + 0.9997772, + 1.0 + ], + [ + -0.000026530815, + 0.99999917, + 1.0 + ], + [ + -9.810042e-9, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.2699671, + 0.992111, + 1.0 + ], + [ + -0.01619867, + 0.99955577, + 1.0 + ], + [ + -0.0000889456, + 0.99999845, + 1.0 + ], + [ + -7.3219084e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.84938484, + 0.9749287, + 1.0 + ], + [ + -0.0546363, + 0.99835104, + 1.0 + ], + [ + -0.00042785166, + 0.99998784, + 1.0 + ], + [ + -6.3949227e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.9408712, + 0.91166395, + 1.0 + ], + [ + -0.28487697, + 0.99084896, + 1.0 + ], + [ + -0.0041404446, + 0.99987274, + 1.0 + ], + [ + -0.00000840042, + 0.99999976, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.1285825, + 0.7972419, + 1.0 + ], + [ + -0.82483685, + 0.96542543, + 1.0 + ], + [ + -0.007825383, + 0.99962455, + 1.0 + ], + [ + -0.0000082233155, + 0.9999996, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.914042, + 0.6662763, + 1.0 + ], + [ + -3.2296083, + 0.84786505, + 1.0 + ], + [ + -0.09797766, + 0.9948829, + 1.0 + ], + [ + -0.000322063, + 0.999986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.49761, + 0.5991851, + 1.0 + ], + [ + -8.209785, + 0.6332046, + 1.0 + ], + [ + -2.2356362, + 0.88977855, + 1.0 + ], + [ + -0.03691413, + 0.9986572, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.16435, + 0.6105177, + 1.0 + ], + [ + -8.482127, + 0.6289409, + 1.0 + ], + [ + -8.195307, + 0.6653821, + 1.0 + ], + [ + -1.5681657, + 0.93180954, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.227832, + 0.6095772, + 1.0 + ], + [ + -9.688233, + 0.62955517, + 1.0 + ], + [ + -9.770227, + 0.6534116, + 1.0 + ], + [ + -7.684848, + 0.7312607, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.040249, + 0.61375934, + 1.0 + ], + [ + -9.414042, + 0.64601433, + 1.0 + ], + [ + -9.124609, + 0.6859369, + 1.0 + ], + [ + -6.8619404, + 0.76940507, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.1956196, + 0.65328926, + 1.0 + ], + [ + -6.4505258, + 0.6780245, + 1.0 + ], + [ + -6.7652173, + 0.777878, + 1.0 + ], + [ + -5.9136004, + 0.9036394, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8208507, + 0.76508826, + 1.0 + ], + [ + -1.3313887, + 0.8808544, + 1.0 + ], + [ + -9.083516, + 0.86273944, + 1.0 + ], + [ + -10.492065, + 0.8505087, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.051133215, + 0.93782914, + 1.0 + ], + [ + -0.07260832, + 0.9849166, + 1.0 + ], + [ + -0.9264943, + 0.9826398, + 1.0 + ], + [ + -1.1677287, + 0.97983795, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.07751575, + 0.97048557, + 1.0 + ], + [ + -0.043188777, + 0.9823848, + 1.0 + ], + [ + -0.002534433, + 0.99882054, + 1.0 + ], + [ + -0.00013920956, + 0.9999524, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.07916742, + 0.97018796, + 1.0 + ], + [ + -0.031966496, + 0.97699416, + 1.0 + ], + [ + -0.0035643438, + 0.99836385, + 1.0 + ], + [ + -0.00040987533, + 0.99998116, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.01877379, + 0.9972593, + 1.0 + ], + [ + 0.01205297, + 0.9989108, + 1.0 + ], + [ + -0.0013474375, + 0.99986976, + 1.0 + ], + [ + -0.0007788474, + 0.9999769, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.00081947824, + 0.99965537, + 1.0 + ], + [ + 0.00408295, + 0.9999879, + 1.0 + ], + [ + -0.0004688936, + 0.9999954, + 1.0 + ], + [ + -0.00016395445, + 0.9999985, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0022904403, + 0.9998439, + 1.0 + ], + [ + -0.0030316855, + 0.9999691, + 1.0 + ], + [ + -0.008458131, + 0.9999591, + 1.0 + ], + [ + -0.0015517423, + 0.99999255, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.008486173, + 0.9998458, + 1.0 + ], + [ + 0.00014019103, + 0.99998724, + 1.0 + ], + [ + -0.0017433885, + 0.99999154, + 1.0 + ], + [ + -0.00032057063, + 0.99999845, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.005678298, + 0.9997919, + 1.0 + ], + [ + -0.0006329326, + 0.99999183, + 1.0 + ], + [ + -0.000014824767, + 1.0, + 1.0 + ], + [ + -0.0000014804741, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.024680974, + 0.9990245, + 1.0 + ], + [ + 0.0004288364, + 0.9999345, + 1.0 + ], + [ + -0.000051545532, + 0.99999934, + 1.0 + ], + [ + -0.000005476217, + 0.99999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0061284457, + 0.9994695, + 1.0 + ], + [ + -0.0008772137, + 0.9999514, + 1.0 + ], + [ + -0.0005471564, + 0.9999964, + 1.0 + ], + [ + -0.00011416538, + 0.99999934, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.019823374, + 0.99714184, + 1.0 + ], + [ + 0.0006233722, + 0.99973315, + 1.0 + ], + [ + -0.00020987287, + 0.99999684, + 1.0 + ], + [ + -0.000045174842, + 0.99999976, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0055233804, + 0.99932915, + 1.0 + ], + [ + 0.00019314369, + 0.999932, + 1.0 + ], + [ + -0.000004102488, + 0.9999994, + 1.0 + ], + [ + -3.2671397e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.017069608, + 0.99980605, + 1.0 + ], + [ + 0.0011378126, + 0.9999736, + 1.0 + ], + [ + 0.0000045061006, + 0.9999994, + 1.0 + ], + [ + 4.9558377e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0025704305, + 0.9998648, + 1.0 + ], + [ + -0.00016181398, + 0.9999653, + 1.0 + ], + [ + -0.000009765289, + 0.9999992, + 1.0 + ], + [ + -2.9514266e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.011738374, + 0.99957114, + 1.0 + ], + [ + 0.0013190353, + 0.99990016, + 1.0 + ], + [ + 9.2287684e-7, + 0.99999744, + 1.0 + ], + [ + -0.0000011511228, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.04013104, + 0.99884504, + 1.0 + ], + [ + 0.00018569271, + 0.99945897, + 1.0 + ], + [ + -0.0010192711, + 0.9999544, + 1.0 + ], + [ + -0.000013688198, + 0.9999996, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.02411725, + 0.99782336, + 1.0 + ], + [ + -0.09172129, + 0.9966743, + 1.0 + ], + [ + -0.017824568, + 0.9994446, + 1.0 + ], + [ + -0.00024212734, + 0.9999933, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0085046105, + 0.9991983, + 1.0 + ], + [ + -0.032236904, + 0.9988923, + 1.0 + ], + [ + -0.03258688, + 0.9995004, + 1.0 + ], + [ + -0.022160465, + 0.99972326, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.047657337, + 0.9974346, + 1.0 + ], + [ + -0.17287616, + 0.9967666, + 1.0 + ], + [ + -1.7228909, + 0.97827595, + 1.0 + ], + [ + -1.5301245, + 0.9807228, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.1092619, + 0.9939699, + 1.0 + ], + [ + -0.28823772, + 0.9942406, + 1.0 + ], + [ + -2.4151208, + 0.9694442, + 1.0 + ], + [ + -2.120977, + 0.973369, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.046760842, + 0.99341387, + 1.0 + ], + [ + -0.46058732, + 0.9879337, + 1.0 + ], + [ + -0.9992375, + 0.97709274, + 1.0 + ], + [ + -0.27791965, + 0.9942543, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.055317637, + 0.988727, + 1.0 + ], + [ + -0.7323104, + 0.971684, + 1.0 + ], + [ + -2.933847, + 0.90179116, + 1.0 + ], + [ + -1.3042065, + 0.95250934, + 1.0 + ] + ], + "srgb_value": true + } +} \ No newline at end of file diff --git a/clippy.toml b/clippy.toml index 4b6c79f55..287e14edc 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,6 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true 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)" }, +] diff --git a/contributors/people.toml b/contributors/people.toml index 0b7f9ee43..95e58421a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -70,3 +70,7 @@ sync_github_name = true [people.ronibalthazar] sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 6b0c97f5a..40e001538 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -822,714 +822,4 @@ impl Catalog { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Filter, Photo, PhotoId, Source}; - - fn lib(kws: &[&[&str]]) -> (Catalog, Vec) { - let mut c = Catalog::new(); - let mut ids = Vec::new(); - for k in kws { - let id = c.alloc_photo_id(); - let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); - p.meta.keywords = k.iter().map(|s| s.to_string()).collect(); - c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); - ids.push(id); - } - (c, ids) - } - fn kws(c: &Catalog, id: PhotoId) -> Vec { - c.photo(id).unwrap().meta.keywords.clone() - } - - #[test] - fn tree_counts_photos_per_level() { - let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]); - let t = c.keyword_tree(); - assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::>(), vec![("beach", 2), ("travel", 3)]); - let travel = &t[1]; - assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::>(), vec![("travel|France", 1), ("travel|Italy", 3)]); - assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome"); - assert_eq!(travel.children[1].children[0].count, 2); - // filtering by a parent finds its children - let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() }; - assert_eq!(c.query(&f, &Default::default()).len(), 3); - } - - /// A keyword written twice on a photo, or in two spellings, counts the photo once; the next - /// photo with it counts again. - #[test] - fn tree_counts_a_repeated_keyword_once_per_photo() { - let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]); - let t = c.keyword_tree(); - assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]); - } - - #[test] - fn rename_delete_merge_are_single_undoable_batches() { - let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]); - let before = c.to_snapshot(); - let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap(); - let Op::Batch { ops } = &op else { panic!() }; - assert_eq!(ops.len(), 3, "only photos that change"); - let inv = c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]); - assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]); - c.apply(inv).unwrap(); - assert_eq!(c.to_snapshot(), before); - // merge `italia` into the existing hierarchical keyword: duplicates collapse - let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[2]), ["travel|italy"]); - // delete removes the keyword and its children everywhere - let op = c.delete_keyword_ops("TRAVEL").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["beach"]); - assert!(kws(&c, ids[1]).is_empty()); - assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]); - // invalid requests - assert!(c.rename_keyword_ops("beach", " ").is_err()); - assert!(c.rename_keyword_ops("a", "a|b").is_err()); - assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err()); - assert_eq!(clean(" a | |b "), "a|b"); - } - - fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> { - let mut out = Vec::new(); - for n in nodes { - out.push((n.path.clone(), n.count)); - out.extend(tree_paths(&n.children)); - } - out - } - - /// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag): - /// it is in the tree with no photos, its parents too, and undo takes it away. - #[test] - fn a_keyword_listed_with_no_photos_is_in_the_tree() { - let (mut c, _) = lib(&[&["travel|France"]]); - let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]); - assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case"); - c.apply(undo).unwrap(); - assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]); - assert_eq!(c.keyword_info("travel|Italy"), None); - } - - /// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported, - /// with the keywords containing it and its synonyms; not a person. - #[test] - fn a_listed_keyword_needs_a_name_and_starts_exported() { - let mut c = Catalog::new(); - assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err()); - let info = KeywordInfo::default(); - assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty()); - } - - /// Changing a listed keyword's attributes is undone to the attributes it had, under the name as - /// it was written. - #[test] - fn changing_a_listed_keyword_is_undone_to_what_it_was() { - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() }; - let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap(); - assert_eq!(c.keyword_info("Weddings"), Some(&person)); - assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }); - c.apply(undo).unwrap(); - assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default())); - } - - fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> { - let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect(); - v.sort_by_key(|(p, _)| p.to_lowercase()); - v - } - - fn with_synonyms(words: &[&str]) -> KeywordInfo { - KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() } - } - - /// Renaming (or moving) a keyword takes its attributes and its listed children along, photos - /// or not, in the same undo step. - #[test] - fn renaming_a_keyword_carries_its_listing_along() { - let (mut c, ids) = lib(&[&["travel|italy"]]); - c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap(); - c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let before = c.to_snapshot(); - let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]); - assert_eq!( - listed(&c), - [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())] - ); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - } - - /// A keyword only the list holds (no photos) can be renamed too. - #[test] - fn a_keyword_without_photos_can_be_renamed() { - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]); - } - - /// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target - /// not listed yet takes the merged keyword's attributes. - #[test] - fn merging_keeps_the_targets_attributes_and_gathers_synonyms() { - let (mut c, _) = lib(&[&["holiday"], &["travel"]]); - let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) }; - c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap(); - c.apply(Op::SetKeyword { - path: "holiday".into(), - info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }), - }) - .unwrap(); - let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]); - // onto a keyword that isn't listed: it takes the merged one's attributes - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap(); - let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]); - } - - /// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos. - #[test] - fn deleting_a_keyword_takes_it_off_the_list() { - let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]); - for path in ["travel", "travel|spain", "beach"] { - c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); - } - let before = c.to_snapshot(); - let op = c.delete_keyword_ops("travel").unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]); - assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - } - - /// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo - /// step. A keyword that exists already (listed, or on a photo) isn't created again. - #[test] - fn creating_a_keyword_lists_it_and_can_tag_photos() { - let (mut c, ids) = lib(&[&["beach"], &[]]); - let before = c.to_snapshot(); - let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]); - assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - for exists in ["BEACH", "events|weddings"] { - if exists == "events|weddings" { - let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap(); - c.apply(op).unwrap(); - } - assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}"); - } - assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err()); - } - - /// Moving a keyword nests it inside another (or takes it to the top level), with its children, - /// on photos and in the list. It can't go inside itself or one of its children, and moving it - /// where it already is changes nothing. - #[test] - fn moving_a_keyword_nests_it_inside_another() { - let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]); - // typed in any case, the keywords keep the spelling the library has - let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]); - let op = c.move_keyword_ops("europe|italy", None, false).unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level"); - assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child"); - assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself"); - assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there"); - assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword"); - } - - /// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move - /// asks first: refused unless merging is allowed. - #[test] - fn moving_onto_a_taken_name_merges_only_when_allowed() { - let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]); - let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err(); - assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}"); - let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap(); - c.apply(op).unwrap(); - assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()])); - } - - /// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes, - /// in one undo step. A name that is taken is refused: merging is its own action. - #[test] - fn editing_a_keyword_renames_it_and_sets_its_attributes() { - let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]); - let before = c.to_snapshot(); - let person = KeywordInfo { person: true, ..KeywordInfo::default() }; - let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["travel|Italia"]); - assert_eq!(c.keyword_info("travel|italia"), Some(&person)); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - // attributes alone - let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]); - assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_)))); - assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path"); - assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err()); - } - - /// Purging takes off the list the keywords no photo has (nor any keyword below them), in one - /// undo step; keywords photos carry stay. - #[test] - fn purging_takes_unused_keywords_off_the_list() { - let (mut c, _) = lib(&[&["travel|italy"]]); - for path in ["travel", "travel|italy", "travel|spain", "weddings"] { - c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); - } - let op = c.purge_unused_keywords_ops(); - c.apply(op).unwrap(); - assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel", "travel|italy"]); - assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]); - } - - /// Renaming matches names whatever their case, also where a letter's case changes its length - /// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. - #[test] - fn renaming_keeps_the_levels_below_whatever_the_case() { - let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]); - let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Road|nord"]); - let op = c.rename_keyword_ops("ẞA", "Weg").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); - } - - fn exported(c: &Catalog, keywords: &[&str]) -> (Vec, Vec) { - let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::>()); - (e.flat, e.hierarchical) - } - - /// By default a keyword is exported as its name together with the keywords containing it (flat, - /// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once. - #[test] - fn a_keyword_exports_its_name_its_parents_and_its_path() { - let c = Catalog::new(); - let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]); - assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]); - assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]); - } - - /// A keyword not included on export is left out, path and all; a parent left out (a keyword that - /// only organizes others) isn't exported as a containing keyword, but its children are. - #[test] - fn keywords_left_out_of_export_stay_out() { - let mut c = Catalog::new(); - let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; - c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); - c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); - let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]); - assert_eq!(flat, ["Lisbon"]); - assert_eq!(paths, ["Places|Lisbon"]); - } - - /// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go - /// out with it unless "export synonyms" is off. - #[test] - fn containing_keywords_and_synonyms_follow_their_options() { - let mut c = Catalog::new(); - let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) }; - c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap(); - let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) }; - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap(); - let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]); - assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); - } - - /// Names match whatever the case of any letter, not only ASCII ones: changing only the case of - /// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing. - #[test] - fn names_match_whatever_the_case_of_any_letter() { - let (mut c, ids) = lib(&[&["Ärzte|Wien"]]); - let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]); - assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] }); - let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]); - assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); - } - - /// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a - /// keyword like any other: it is in the tree, so it can be found, moved and renamed. - #[test] - fn the_parent_of_a_listed_keyword_is_a_keyword() { - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert!(c.has_keyword("events")); - assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events")); - let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap(); - c.apply(op).unwrap(); - assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]); - } - - /// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it - /// can be created again and is purged as unused, and its spelling there isn't the library's. - #[test] - fn deleted_photos_dont_hold_keywords() { - let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]); - c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap(); - assert!(!c.has_keyword("weddings")); - assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok()); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let op = c.purge_unused_keywords_ops(); - c.apply(op).unwrap(); - assert!(listed(&c).is_empty(), "only a deleted photo has it: unused"); - } - - /// Merging several keywords moves each keyword under them by the closest one, whatever the - /// order they are given in, and every listing lands where its keyword does: one landing where - /// another is about to leave isn't merged into it. - #[test] - fn merging_overlapping_keywords_keeps_every_listing() { - let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]); - let person = KeywordInfo { person: true, ..KeywordInfo::default() }; - c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap(); - c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap(); - let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap(); - c.apply(op).unwrap(); - assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()])); - assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]); - // the order of the keywords merged doesn't matter - let merged = |from: [&str; 2]| { - let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]); - let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap(); - c.apply(op).unwrap(); - kws(&c, ids[0]) - }; - assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"])); - assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]); - } - - /// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making - /// another one so is one undo step; the mark moves with its keyword (also into a merge) and - /// goes with it. - #[test] - fn one_keyword_is_where_new_keywords_go() { - let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]); - let op = c.set_default_parent_ops(Some("events")).unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Events")); - let op = c.set_default_parent_ops(Some("travel")).unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one"); - assert!(c.set_default_parent_ops(Some("lisbon")).is_err()); - let op = c.rename_keyword_ops("travel", "Trips").unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips")); - let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge"); - let op = c.delete_keyword_ops("events").unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent(), None); - let op = c.set_default_parent_ops(None).unwrap(); - assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear"); - c.apply(undo).unwrap(); - assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); - } - - /// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the - /// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes - /// back as one keyword, not three). Without paths, the names as they are. - #[test] - fn a_files_keywords_keep_their_hierarchy() { - let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]); - assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned"); - assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]); - assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); - } - - /// A new keyword inside one the library has takes the library's spelling of it: creating - /// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel". - #[test] - fn a_new_keyword_keeps_the_spelling_of_its_parents() { - let (mut c, _) = lib(&[&["travel|italy"]]); - let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel|Rome"]); - assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel"); - } - - /// An edit that changes nothing is nothing: no listing, no undo step. - #[test] - fn an_edit_that_changes_nothing_is_nothing() { - let (mut c, _) = lib(&[&["beach"]]); - assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] }); - c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap(); - assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] }); - // renamed with the default attributes: the photos change, nothing is listed - let (mut c, _) = lib(&[&["beach"]]); - let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap(); - c.apply(op).unwrap(); - assert!(listed(&c).is_empty()); - } - - /// Typing suggests keywords no photo has yet too (created in the Keyword List). - #[test] - fn suggestions_include_keywords_without_photos() { - let (mut c, _) = lib(&[&["beach"]]); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]); - assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has"); - } - - /// A new name typed for a photo goes inside the default parent; a keyword the library has, and - /// a path typed whole, don't. - #[test] - fn typed_new_keywords_go_inside_the_default_parent() { - let (mut c, _) = lib(&[&["beach"], &["Events"]]); - assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent"); - let op = c.set_default_parent_ops(Some("events")).unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings"); - assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it"); - assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon"); - } - - /// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the - /// keywords inside it indented one tab more, a synonym in braces on a line of its own under its - /// keyword, a keyword left out of export in square brackets; by name. - #[test] - fn the_keyword_list_is_written_as_tab_indented_text() { - let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]); - c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); - c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap(); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n"); - } - - /// Reading a list gives each keyword's path and attributes: what was written comes back, and a - /// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too. - #[test] - fn a_keyword_list_reads_back() { - let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap(); - let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; - assert_eq!( - read, - [ - ("beach".to_string(), KeywordInfo::default()), - ("Events".to_string(), KeywordInfo::default()), - ("Events|Weddings".to_string(), with_synonyms(&["marriage"])), - ("Places".to_string(), out), - ("Places|Lisbon".to_string(), KeywordInfo::default()), - ] - ); - } - - /// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line - /// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword, - /// left out of export), keywords a tab further in at each level. - #[test] - fn a_photo_supreme_vocabulary_file_reads() { - let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n"; - let read = parse_keyword_list(file).unwrap(); - let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect(); - assert_eq!( - paths, - ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"] - .map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") }) - ); - assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported"); - assert!(read[1..].iter().all(|(_, i)| i.include_on_export)); - } - - /// A list that can't be read says which line and why, and nothing of it is taken. - #[test] - fn a_bad_keyword_list_says_which_line() { - let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); - assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once"); - assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing"); - assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here"); - let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect(); - assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most"); - } - - /// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone - /// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is - /// refused at its line; so are control characters in a name and "|" in a synonym. - #[test] - fn a_keyword_list_from_elsewhere_reads_or_says_why_not() { - let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::>(); - assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]); - let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); - assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation"); - assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs"); - assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character"); - assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym"); - } - - /// A keyword repeated in another case is one keyword, and the keywords inside either take the - /// first spelling. - #[test] - fn a_repeated_keyword_keeps_its_first_spelling() { - let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap(); - assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Events", "Events|Weddings", "Events|Birthdays"]); - } - - /// What the list can't hold is left out and named, so the file always reads back: a name the - /// format would read as something else (in brackets or braces), one with a line break, a - /// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left - /// out go with it. - #[test] - fn an_exported_keyword_list_always_reads_back() { - let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::>().join("|"); - let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap(); - let KeywordListFile { text, unwritable, .. } = c.keyword_list_text(); - let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); - assert!(read.iter().any(|(p, _)| p == "Travel")); - assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}"); - let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); - assert_eq!(weddings, Some(vec!["marriage".to_string()])); - let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max(); - assert_eq!(deepest, Some(MAX_LEVELS)); - assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}"); - assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}"); - assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}"); - assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}"); - assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}"); - } - - /// The list leaves out only what it must, and names only what's lost: a name starting with a - /// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty - /// or another's in a different case is no loss. It also says which keywords it wrote. - #[test] - fn an_exported_keyword_list_names_only_what_it_loses() { - let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap(); - let out = c.keyword_list_text(); - let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text)); - assert_eq!(out.unwritable, ["\u{feff}Albums"]); - assert_eq!(out.written, ["Weddings", "{draft"]); - assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Weddings", "{draft"]); - let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); - assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()])); - } - - /// A big list reads and imports at once, against a big library too: 40,000 keywords used to take - /// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with - /// the app frozen. - #[test] - fn a_big_keyword_list_imports_quickly() { - let mut text = String::new(); - for g in 0..400 { - text.push_str(&format!("group {g}\n")); - for k in 0..99 { - text.push_str(&format!("\tkeyword {g}-{k}\n")); - } - } - let t0 = std::time::Instant::now(); - let read = parse_keyword_list(&text).unwrap(); - assert_eq!(read.len(), 40_000); - let mut c = Catalog::new(); - let mut ops = Vec::new(); - for i in 0..20_000u32 { - let id = c.alloc_photo_id(); - let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); - p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect(); - ops.push(Op::AddPhoto { photo: Box::new(p) }); - } - c.apply(Op::Batch { ops }).unwrap(); - let (op, added, _) = c.import_keywords_ops(&read[..5_000]); - c.apply(op).unwrap(); - assert_eq!(added, 5_000); - let took = t0.elapsed(); - assert!(took < std::time::Duration::from_secs(10), "{took:?}"); - } - - /// Importing a list adds the keywords the library doesn't have, with their attributes, and gives - /// those it has the list's synonyms (their own attributes stay), in one undo step. - #[test] - fn importing_a_keyword_list_adds_what_is_missing() { - let (mut c, _) = lib(&[&["Events|Weddings"]]); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap(); - let before = c.to_snapshot(); - let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap(); - let (op, added, updated) = c.import_keywords_ops(&read); - assert_eq!((added, updated), (2, 1)); - let undo = c.apply(op).unwrap(); - assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) })); - assert!(c.has_keyword("Events|Birthdays")); - assert_eq!( - listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), - ["Drafts", "Events|Birthdays", "Events|Weddings"], - "in the library's spelling" - ); - assert!(!c.keyword_info("Drafts").unwrap().include_on_export); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - } - - #[test] - fn suggestions_rank_co_occurrence_and_prefixes() { - let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); - // co-occurring with `dog` first (park twice), then the most used - let s = c.keyword_suggestions(&["dog".into()], "", 3); - assert_eq!(s, ["park", "ball", "beach"]); - let s = c.keyword_suggestions(&[], "", 2); - assert_eq!(s, ["cat", "dog"]); - let s = c.keyword_suggestions(&["park".into()], "pa", 5); - assert_eq!(s, ["parade"]); - let s = c.keyword_suggestions(&[], "E", 5); - assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches"); - } - - /// A flat keyword already covered by a hierarchical level only in another spelling is not kept - /// again, whatever the size of either list (the per-flat-keyword search of every level, twice - /// lower-cased per comparison, was quadratic: CWE-407). - #[test] - fn from_file_matches_levels_in_any_case() { - let flat = ["Rome".to_string(), "Seaside".to_string()]; - let hierarchical = ["travel|ITALY|rome".to_string()]; - assert_eq!(from_file(&flat, &hierarchical), ["travel|ITALY|rome", "Seaside"]); - } - - /// The same synonym, in any spelling, is kept once (the search of each keyword's growing - /// synonym list per line was quadratic, with lower-cased copies made per comparison).(CWE-407) - #[test] - fn parse_keyword_list_keeps_a_synonym_once() { - let read = parse_keyword_list("Travel\n\t{Trip}\n\t{journey}\n\t{TRIP}\n").unwrap(); - assert_eq!(read.len(), 1); - assert_eq!(read[0].1.synonyms, ["Trip", "journey"]); - } - - /// A keyword split into far more levels than a list file accepts is cut at [`MAX_LEVELS`] in the - /// tree, whose level rows hold every prefix twice: unbounded it made the stored bytes quadratic - /// in the level count of one short keyword (CWE-400). - #[test] - fn keyword_tree_caps_a_keyword_at_the_list_depth() { - let deep: Vec = (0..4096).map(|i| format!("k{i}")).collect(); - let joined = deep.join("|"); - let (c, _) = lib(&[&[joined.as_str()]]); - let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0); - assert_eq!(deepest, MAX_LEVELS); - } -} +mod tests; diff --git a/crates/catalog/src/keywords/tests.rs b/crates/catalog/src/keywords/tests.rs new file mode 100644 index 000000000..df65606ef --- /dev/null +++ b/crates/catalog/src/keywords/tests.rs @@ -0,0 +1,706 @@ +use super::*; +use crate::{Filter, Photo, PhotoId, Source}; + +fn lib(kws: &[&[&str]]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for k in kws { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = k.iter().map(|s| s.to_string()).collect(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) +} +fn kws(c: &Catalog, id: PhotoId) -> Vec { + c.photo(id).unwrap().meta.keywords.clone() +} + +#[test] +fn tree_counts_photos_per_level() { + let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]); + let t = c.keyword_tree(); + assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::>(), vec![("beach", 2), ("travel", 3)]); + let travel = &t[1]; + assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::>(), vec![("travel|France", 1), ("travel|Italy", 3)]); + assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome"); + assert_eq!(travel.children[1].children[0].count, 2); + // filtering by a parent finds its children + let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() }; + assert_eq!(c.query(&f, &Default::default()).len(), 3); +} + +/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next +/// photo with it counts again. +#[test] +fn tree_counts_a_repeated_keyword_once_per_photo() { + let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]); + let t = c.keyword_tree(); + assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]); +} + +#[test] +fn rename_delete_merge_are_single_undoable_batches() { + let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]); + let before = c.to_snapshot(); + let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap(); + let Op::Batch { ops } = &op else { panic!() }; + assert_eq!(ops.len(), 3, "only photos that change"); + let inv = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]); + assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]); + c.apply(inv).unwrap(); + assert_eq!(c.to_snapshot(), before); + // merge `italia` into the existing hierarchical keyword: duplicates collapse + let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[2]), ["travel|italy"]); + // delete removes the keyword and its children everywhere + let op = c.delete_keyword_ops("TRAVEL").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["beach"]); + assert!(kws(&c, ids[1]).is_empty()); + assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]); + // invalid requests + assert!(c.rename_keyword_ops("beach", " ").is_err()); + assert!(c.rename_keyword_ops("a", "a|b").is_err()); + assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err()); + assert_eq!(clean(" a | |b "), "a|b"); +} + +fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> { + let mut out = Vec::new(); + for n in nodes { + out.push((n.path.clone(), n.count)); + out.extend(tree_paths(&n.children)); + } + out +} + +/// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag): +/// it is in the tree with no photos, its parents too, and undo takes it away. +#[test] +fn a_keyword_listed_with_no_photos_is_in_the_tree() { + let (mut c, _) = lib(&[&["travel|France"]]); + let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]); + assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case"); + c.apply(undo).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]); + assert_eq!(c.keyword_info("travel|Italy"), None); +} + +/// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported, +/// with the keywords containing it and its synonyms; not a person. +#[test] +fn a_listed_keyword_needs_a_name_and_starts_exported() { + let mut c = Catalog::new(); + assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err()); + let info = KeywordInfo::default(); + assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty()); +} + +/// Changing a listed keyword's attributes is undone to the attributes it had, under the name as +/// it was written. +#[test] +fn changing_a_listed_keyword_is_undone_to_what_it_was() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() }; + let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap(); + assert_eq!(c.keyword_info("Weddings"), Some(&person)); + assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }); + c.apply(undo).unwrap(); + assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default())); +} + +fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> { + let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect(); + v.sort_by_key(|(p, _)| p.to_lowercase()); + v +} + +fn with_synonyms(words: &[&str]) -> KeywordInfo { + KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() } +} + +/// Renaming (or moving) a keyword takes its attributes and its listed children along, photos +/// or not, in the same undo step. +#[test] +fn renaming_a_keyword_carries_its_listing_along() { + let (mut c, ids) = lib(&[&["travel|italy"]]); + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap(); + c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let before = c.to_snapshot(); + let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]); + assert_eq!(listed(&c), [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +/// A keyword only the list holds (no photos) can be renamed too. +#[test] +fn a_keyword_without_photos_can_be_renamed() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]); +} + +/// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target +/// not listed yet takes the merged keyword's attributes. +#[test] +fn merging_keeps_the_targets_attributes_and_gathers_synonyms() { + let (mut c, _) = lib(&[&["holiday"], &["travel"]]); + let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) }; + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }) }) + .unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]); + // onto a keyword that isn't listed: it takes the merged one's attributes + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]); +} + +/// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos. +#[test] +fn deleting_a_keyword_takes_it_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]); + for path in ["travel", "travel|spain", "beach"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let before = c.to_snapshot(); + let op = c.delete_keyword_ops("travel").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]); + assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +/// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo +/// step. A keyword that exists already (listed, or on a photo) isn't created again. +#[test] +fn creating_a_keyword_lists_it_and_can_tag_photos() { + let (mut c, ids) = lib(&[&["beach"], &[]]); + let before = c.to_snapshot(); + let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]); + assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + for exists in ["BEACH", "events|weddings"] { + if exists == "events|weddings" { + let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + } + assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}"); + } + assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err()); +} + +/// Moving a keyword nests it inside another (or takes it to the top level), with its children, +/// on photos and in the list. It can't go inside itself or one of its children, and moving it +/// where it already is changes nothing. +#[test] +fn moving_a_keyword_nests_it_inside_another() { + let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]); + // typed in any case, the keywords keep the spelling the library has + let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]); + let op = c.move_keyword_ops("europe|italy", None, false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level"); + assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child"); + assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself"); + assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there"); + assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword"); +} + +/// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move +/// asks first: refused unless merging is allowed. +#[test] +fn moving_onto_a_taken_name_merges_only_when_allowed() { + let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]); + let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err(); + assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}"); + let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()])); +} + +/// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes, +/// in one undo step. A name that is taken is refused: merging is its own action. +#[test] +fn editing_a_keyword_renames_it_and_sets_its_attributes() { + let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]); + let before = c.to_snapshot(); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["travel|Italia"]); + assert_eq!(c.keyword_info("travel|italia"), Some(&person)); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + // attributes alone + let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]); + assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_)))); + assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path"); + assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err()); +} + +/// Purging takes off the list the keywords no photo has (nor any keyword below them), in one +/// undo step; keywords photos carry stay. +#[test] +fn purging_takes_unused_keywords_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"]]); + for path in ["travel", "travel|italy", "travel|spain", "weddings"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel", "travel|italy"]); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]); +} + +/// Renaming matches names whatever their case, also where a letter's case changes its length +/// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. +#[test] +fn renaming_keeps_the_levels_below_whatever_the_case() { + let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]); + let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Road|nord"]); + let op = c.rename_keyword_ops("ẞA", "Weg").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); +} + +fn exported(c: &Catalog, keywords: &[&str]) -> (Vec, Vec) { + let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::>()); + (e.flat, e.hierarchical) +} + +/// By default a keyword is exported as its name together with the keywords containing it (flat, +/// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once. +#[test] +fn a_keyword_exports_its_name_its_parents_and_its_path() { + let c = Catalog::new(); + let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]); + assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]); + assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]); +} + +/// A keyword not included on export is left out, path and all; a parent left out (a keyword that +/// only organizes others) isn't exported as a containing keyword, but its children are. +#[test] +fn keywords_left_out_of_export_stay_out() { + let mut c = Catalog::new(); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); + let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]); + assert_eq!(flat, ["Lisbon"]); + assert_eq!(paths, ["Places|Lisbon"]); +} + +/// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go +/// out with it unless "export synonyms" is off. +#[test] +fn containing_keywords_and_synonyms_follow_their_options() { + let mut c = Catalog::new(); + let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) }; + c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap(); + let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) }; + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap(); + let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]); + assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); +} + +/// Names match whatever the case of any letter, not only ASCII ones: changing only the case of +/// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing. +#[test] +fn names_match_whatever_the_case_of_any_letter() { + let (mut c, ids) = lib(&[&["Ärzte|Wien"]]); + let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]); + assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] }); + let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]); + assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); +} + +/// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a +/// keyword like any other: it is in the tree, so it can be found, moved and renamed. +#[test] +fn the_parent_of_a_listed_keyword_is_a_keyword() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert!(c.has_keyword("events")); + assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events")); + let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]); +} + +/// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it +/// can be created again and is purged as unused, and its spelling there isn't the library's. +#[test] +fn deleted_photos_dont_hold_keywords() { + let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]); + c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap(); + assert!(!c.has_keyword("weddings")); + assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok()); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty(), "only a deleted photo has it: unused"); +} + +/// Merging several keywords moves each keyword under them by the closest one, whatever the +/// order they are given in, and every listing lands where its keyword does: one landing where +/// another is about to leave isn't merged into it. +#[test] +fn merging_overlapping_keywords_keeps_every_listing() { + let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap(); + let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()])); + assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]); + // the order of the keywords merged doesn't matter + let merged = |from: [&str; 2]| { + let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]); + let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap(); + c.apply(op).unwrap(); + kws(&c, ids[0]) + }; + assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"])); + assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]); +} + +/// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making +/// another one so is one undo step; the mark moves with its keyword (also into a merge) and +/// goes with it. +#[test] +fn one_keyword_is_where_new_keywords_go() { + let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events")); + let op = c.set_default_parent_ops(Some("travel")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one"); + assert!(c.set_default_parent_ops(Some("lisbon")).is_err()); + let op = c.rename_keyword_ops("travel", "Trips").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips")); + let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge"); + let op = c.delete_keyword_ops("events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent(), None); + let op = c.set_default_parent_ops(None).unwrap(); + assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear"); + c.apply(undo).unwrap(); + assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); +} + +/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the +/// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes +/// back as one keyword, not three). Without paths, the names as they are. +#[test] +fn a_files_keywords_keep_their_hierarchy() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]); + assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned"); + assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]); + assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); +} + +/// A new keyword inside one the library has takes the library's spelling of it: creating +/// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel". +#[test] +fn a_new_keyword_keeps_the_spelling_of_its_parents() { + let (mut c, _) = lib(&[&["travel|italy"]]); + let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel|Rome"]); + assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel"); +} + +/// An edit that changes nothing is nothing: no listing, no undo step. +#[test] +fn an_edit_that_changes_nothing_is_nothing() { + let (mut c, _) = lib(&[&["beach"]]); + assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] }); + c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap(); + assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] }); + // renamed with the default attributes: the photos change, nothing is listed + let (mut c, _) = lib(&[&["beach"]]); + let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty()); +} + +/// Typing suggests keywords no photo has yet too (created in the Keyword List). +#[test] +fn suggestions_include_keywords_without_photos() { + let (mut c, _) = lib(&[&["beach"]]); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]); + assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has"); +} + +/// A new name typed for a photo goes inside the default parent; a keyword the library has, and +/// a path typed whole, don't. +#[test] +fn typed_new_keywords_go_inside_the_default_parent() { + let (mut c, _) = lib(&[&["beach"], &["Events"]]); + assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent"); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings"); + assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it"); + assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon"); +} + +/// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the +/// keywords inside it indented one tab more, a synonym in braces on a line of its own under its +/// keyword, a keyword left out of export in square brackets; by name. +#[test] +fn the_keyword_list_is_written_as_tab_indented_text() { + let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]); + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap(); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n"); +} + +/// Reading a list gives each keyword's path and attributes: what was written comes back, and a +/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too. +#[test] +fn a_keyword_list_reads_back() { + let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap(); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + assert_eq!( + read, + [ + ("beach".to_string(), KeywordInfo::default()), + ("Events".to_string(), KeywordInfo::default()), + ("Events|Weddings".to_string(), with_synonyms(&["marriage"])), + ("Places".to_string(), out), + ("Places|Lisbon".to_string(), KeywordInfo::default()), + ] + ); +} + +/// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line +/// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword, +/// left out of export), keywords a tab further in at each level. +#[test] +fn a_photo_supreme_vocabulary_file_reads() { + let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n"; + let read = parse_keyword_list(file).unwrap(); + let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect(); + assert_eq!( + paths, + ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"].map(|p| if p == "Places" { + p.to_string() + } else { + format!("Places|{p}") + }) + ); + assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported"); + assert!(read[1..].iter().all(|(_, i)| i.include_on_export)); +} + +/// A list that can't be read says which line and why, and nothing of it is taken. +#[test] +fn a_bad_keyword_list_says_which_line() { + let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); + assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once"); + assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing"); + assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here"); + let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect(); + assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most"); +} + +/// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone +/// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is +/// refused at its line; so are control characters in a name and "|" in a synonym. +#[test] +fn a_keyword_list_from_elsewhere_reads_or_says_why_not() { + let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::>(); + assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]); + let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); + assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation"); + assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs"); + assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character"); + assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym"); +} + +/// A keyword repeated in another case is one keyword, and the keywords inside either take the +/// first spelling. +#[test] +fn a_repeated_keyword_keeps_its_first_spelling() { + let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap(); + assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Events", "Events|Weddings", "Events|Birthdays"]); +} + +/// What the list can't hold is left out and named, so the file always reads back: a name the +/// format would read as something else (in brackets or braces), one with a line break, a +/// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left +/// out go with it. +#[test] +fn an_exported_keyword_list_always_reads_back() { + let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::>().join("|"); + let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap(); + let KeywordListFile { text, unwritable, .. } = c.keyword_list_text(); + let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); + assert!(read.iter().any(|(p, _)| p == "Travel")); + assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}"); + let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); + assert_eq!(weddings, Some(vec!["marriage".to_string()])); + let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max(); + assert_eq!(deepest, Some(MAX_LEVELS)); + assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}"); + assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}"); +} + +/// The list leaves out only what it must, and names only what's lost: a name starting with a +/// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty +/// or another's in a different case is no loss. It also says which keywords it wrote. +#[test] +fn an_exported_keyword_list_names_only_what_it_loses() { + let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap(); + let out = c.keyword_list_text(); + let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text)); + assert_eq!(out.unwritable, ["\u{feff}Albums"]); + assert_eq!(out.written, ["Weddings", "{draft"]); + assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Weddings", "{draft"]); + let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); + assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()])); +} + +/// A big list reads and imports at once, against a big library too: 40,000 keywords used to take +/// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with +/// the app frozen. +#[test] +fn a_big_keyword_list_imports_quickly() { + let mut text = String::new(); + for g in 0..400 { + text.push_str(&format!("group {g}\n")); + for k in 0..99 { + text.push_str(&format!("\tkeyword {g}-{k}\n")); + } + } + let t0 = std::time::Instant::now(); + let read = parse_keyword_list(&text).unwrap(); + assert_eq!(read.len(), 40_000); + let mut c = Catalog::new(); + let mut ops = Vec::new(); + for i in 0..20_000u32 { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect(); + ops.push(Op::AddPhoto { photo: Box::new(p) }); + } + c.apply(Op::Batch { ops }).unwrap(); + let (op, added, _) = c.import_keywords_ops(&read[..5_000]); + c.apply(op).unwrap(); + assert_eq!(added, 5_000); + let took = t0.elapsed(); + assert!(took < std::time::Duration::from_secs(10), "{took:?}"); +} + +/// Importing a list adds the keywords the library doesn't have, with their attributes, and gives +/// those it has the list's synonyms (their own attributes stay), in one undo step. +#[test] +fn importing_a_keyword_list_adds_what_is_missing() { + let (mut c, _) = lib(&[&["Events|Weddings"]]); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap(); + let before = c.to_snapshot(); + let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap(); + let (op, added, updated) = c.import_keywords_ops(&read); + assert_eq!((added, updated), (2, 1)); + let undo = c.apply(op).unwrap(); + assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) })); + assert!(c.has_keyword("Events|Birthdays")); + assert_eq!( + listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), + ["Drafts", "Events|Birthdays", "Events|Weddings"], + "in the library's spelling" + ); + assert!(!c.keyword_info("Drafts").unwrap().include_on_export); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn suggestions_rank_co_occurrence_and_prefixes() { + let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); + // co-occurring with `dog` first (park twice), then the most used + let s = c.keyword_suggestions(&["dog".into()], "", 3); + assert_eq!(s, ["park", "ball", "beach"]); + let s = c.keyword_suggestions(&[], "", 2); + assert_eq!(s, ["cat", "dog"]); + let s = c.keyword_suggestions(&["park".into()], "pa", 5); + assert_eq!(s, ["parade"]); + let s = c.keyword_suggestions(&[], "E", 5); + assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches"); +} + +/// A flat keyword already covered by a hierarchical level only in another spelling is not kept +/// again, whatever the size of either list (the per-flat-keyword search of every level, twice +/// lower-cased per comparison, was quadratic: CWE-407). +#[test] +fn from_file_matches_levels_in_any_case() { + let flat = ["Rome".to_string(), "Seaside".to_string()]; + let hierarchical = ["travel|ITALY|rome".to_string()]; + assert_eq!(from_file(&flat, &hierarchical), ["travel|ITALY|rome", "Seaside"]); +} + +/// The same synonym, in any spelling, is kept once (the search of each keyword's growing +/// synonym list per line was quadratic, with lower-cased copies made per comparison).(CWE-407) +#[test] +fn parse_keyword_list_keeps_a_synonym_once() { + let read = parse_keyword_list("Travel\n\t{Trip}\n\t{journey}\n\t{TRIP}\n").unwrap(); + assert_eq!(read.len(), 1); + assert_eq!(read[0].1.synonyms, ["Trip", "journey"]); +} + +/// A keyword split into far more levels than a list file accepts is cut at [`MAX_LEVELS`] in the +/// tree, whose level rows hold every prefix twice: unbounded it made the stored bytes quadratic +/// in the level count of one short keyword (CWE-400). +#[test] +fn keyword_tree_caps_a_keyword_at_the_list_depth() { + let deep: Vec = (0..4096).map(|i| format!("k{i}")).collect(); + let joined = deep.join("|"); + let (c, _) = lib(&[&[joined.as_str()]]); + let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0); + assert_eq!(deepest, MAX_LEVELS); +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 7e094200c..453b68a69 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -1098,55 +1098,41 @@ impl Catalog { } st.photos.len() >= 2 }); - let parents: Vec<(AlbumId, Option)> = self.albums.values().map(|a| (a.id, a.parent)).collect(); - // A folder chain is shared by every album under it, so each chain is validated once and its - // verdict is what later albums reused: a valid chain encoded in a flat snapshot made the old - // per-album walk quadratic in the album count (CWE-407). - enum Chain { - Ok, - Bad, - } - let mut chains: std::collections::HashMap = std::collections::HashMap::new(); - for (id, parent) in parents { - let Some(p) = parent else { continue }; - if chains.contains_key(&id) { - continue; - } - // Walk up from `p` once, recording everything seen; what it reaches decides every node - // walked (a chain already judged by an earlier album stops the walk early — the rest of - // the chain was judged then). + let albums: Vec = self.albums.keys().copied().collect(); + // Validate each parent link once (CWE-407), caching only chains that now reach a root. + // A cached bad verdict is not enough: skipping its ancestors would leave their broken + // links/cycle intact when a lower-id child was the first to walk into that chain. + let mut valid: std::collections::HashSet = std::collections::HashSet::new(); + for id in albums { let mut walked: Vec = Vec::new(); - let ok = { - let mut cur = Some(p); + let broken = { + let mut cur = id; let mut on: std::collections::HashSet = std::collections::HashSet::new(); loop { - match cur { - None => break true, - Some(c) => { - if c == id || !on.insert(c) { - break false; - } - match chains.get(&c) { - Some(Chain::Bad) => break false, - Some(Chain::Ok) => break true, - None => {} - } - match self.albums.get(&c) { - Some(a) if a.folder => walked.push(c), - _ => break false, - } - cur = self.albums[&c].parent; - } + if valid.contains(&cur) { + break None; } + if !on.insert(cur) { + // Detach one actual cycle node, not the child that first reached it. + break self.albums.get(&cur).and_then(|a| a.parent).map(|parent| (cur, parent)); + } + walked.push(cur); + let Some(parent) = self.albums.get(&cur).and_then(|a| a.parent) else { break None }; + if !self.albums.get(&parent).is_some_and(|a| a.folder) { + break Some((cur, parent)); + } + cur = parent; } }; - for c in walked { - chains.insert(c, if ok { Chain::Ok } else { Chain::Bad }); - } - if !ok && let Some(a) = self.albums.get_mut(&id) { + if let Some((id, p)) = broken + && let Some(a) = self.albums.get_mut(&id) + { changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0)); a.parent = None; } + // Only visited nodes are cached, after every one can reach the repaired root or a + // previously validated chain. Unvisited ancestors never inherit a guessed verdict. + valid.extend(walked); } let past = |last: Option| last.map_or(1, |v| v.saturating_add(1)); let (photo, album, stack) = ( diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 2976a2659..ec92dc724 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -1179,789 +1179,4 @@ impl RuleSet { } #[cfg(test)] -mod tests { - use super::*; - use crate::{ColorLabel, Flag, PhotoId, Source}; - use serde_json::json; - - fn photo(id: u64) -> Photo { - let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00"); - p.rating = 4; - p.flag = Flag::Pick; - p.label = Some(ColorLabel::Red); - p.captured = Some("2026-08-14T18:30:00".into()); - p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()]; - p.meta.camera = "Model X2".into(); - p.meta.iso = Some(1600); - p.meta.aperture = Some(2.8); - p - } - - fn rs(v: serde_json::Value) -> RuleSet { - serde_json::from_value(v).unwrap() - } - - #[test] - fn f32_boundary_values_match_gte_lte() { - let cat = Catalog::new(); - let matches = - |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat); - for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] { - let mut p = photo(1); - p.meta.aperture = Some(v); - p.meta.focal_mm = Some(v); - let w = v.to_string().parse::().unwrap(); - for field in ["aperture", "focalLength"] { - assert!(matches(&p, field, "gte", w), "{field} gte {w}"); - assert!(matches(&p, field, "lte", w), "{field} lte {w}"); - assert!(!matches(&p, field, "gt", w), "{field} gt {w}"); - assert!(!matches(&p, field, "lt", w), "{field} lt {w}"); - } - } - } - - #[test] - fn fields_ops_and_groups() { - let cat = Catalog::new(); - let p = photo(1); - let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}"); - let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}"); - yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]})); - no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]})); - yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]})); - no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]})); - yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]})); - yes( - json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}), - ); - no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]})); - yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); - // file path: the whole string (spaces included), either separator, case-insensitive - let mut fp = photo(3); - fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() }; - let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]}))); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); - no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path - yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); - yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); - yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); - yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]})); - yes( - json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}), - ); - yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]})); - no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]})); - yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]})); - // copyright status (unknown until set) - yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]})); - no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]})); - let mut pd = photo(2); - pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain; - assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat)); - assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat)); - // nested: rating ≥ 4 and (label is blue or keywords contain food) - yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [ - {"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]})); - // in the last N days, relative to now - set_now(Some("2026-09-01T00:00:00".into())); - yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]})); - no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]})); - yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]})); - set_now(None); - // an empty rule list: all → everything, any → nothing - yes(json!({"rules": []})); - no(json!({"match": "any", "rules": []})); - } - - #[test] - fn problems_and_description() { - let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]})); - let messages: Vec = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect(); - assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); - let r = rs( - json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}), - ); - assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks"); - // every field has a kind with operators - for (f, _, k) in FIELDS { - assert!(!ops_for(*k).is_empty(), "{f}"); - } - } - - /// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path - /// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one. - #[test] - fn keyword_count_rules() { - let cat = Catalog::new(); - let mut p = photo(1); // travel|italy|rome, food - let m = |p: &Photo, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); - assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); - p.meta.keywords.push("Food".into()); - assert!(m(&p, "is", json!(2)), "Food and food are one keyword"); - p.meta.keywords.clear(); - assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2))); - assert_eq!(field_group("keywordCount"), Some("Keywords & People")); - } - - /// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed - /// faces are not people; People Count counts each person once. - #[test] - fn people_rules() { - use lightcraft_meta::{Rect, Region, RegionKind}; - let cat = Catalog::new(); - let region = |name: Option<&str>, kind: RegionKind| Region { - rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, - kind, - name: name.map(str::to_string), - description: None, - }; - let mut p = photo(1); - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0))); - p.meta.regions = vec![ - region(Some("Ana Lima"), RegionKind::Face), - region(Some(" ana lima "), RegionKind::Face), - region(Some("Rex"), RegionKind::Pet), - region(None, RegionKind::Face), - region(Some("Bo"), RegionKind::Face), - ]; - assert!(m(&p, "person", "contains", json!("ana"))); - assert!(m(&p, "person", "is", json!("ANA LIMA"))); - assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person"); - assert!(m(&p, "person", "notContains", json!("rex"))); - assert!(m(&p, "person", "isNotEmpty", json!(null))); - assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face"); - assert_eq!(field_group("person"), Some("Keywords & People")); - } - - /// Shutter Speed compares exposure times in seconds, written as the camera shows them: a - /// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125". - #[test] - fn shutter_speed_rules() { - let cat = Catalog::new(); - let mut p = photo(1); - let m = |p: &Photo, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); - p.meta.shutter = "1/250".into(); - assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); - assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01))); - assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing"); - p.meta.shutter = "2\"".into(); - assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2))); - assert_eq!(field_group("shutterSpeed"), Some("Camera Info")); - } - - #[test] - fn shutter_seconds_parses_camera_notation() { - for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] { - assert_eq!(crate::parse_shutter_seconds(s), want, "{s}"); - } - for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] { - assert_eq!(crate::parse_shutter_seconds(s), None, "{s}"); - } - } - - /// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State / - /// Province and Country are text fields of their own (Location still matches all of them). - #[test] - fn file_source_description_and_location_rules() { - let cat = Catalog::new(); - let mut p = photo(1); // IMG_0042.CR2 - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2"))); - assert!(!m(&p, "extension", "is", json!("jpg"))); - p.file_name = "README".into(); - assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension"); - p.file_name = "archive.tar.gz".into(); - assert!(m(&p, "extension", "is", json!("gz"))); - p.file_name = ".hidden".into(); - assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension"); - assert!(m(&p, "copyName", "isEmpty", json!(null))); - p.copy_name = Some("Black and white".into()); - assert!(m(&p, "copyName", "contains", json!("black"))); - p.meta.alt_text = "A red kite over a hill".into(); - assert!(m(&p, "altText", "contains", json!("kite"))); - p.meta.city = "Porto".into(); - p.meta.state = "Porto District".into(); - p.meta.country = "Portugal".into(); - assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal"))); - assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city"); - assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country"); - } - - /// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment. - #[test] - fn source_file_and_develop_rules() { - use crate::{Op, Stack, StackId}; - let mut cat = Catalog::new(); - let a = photo(1); - let b = photo(2); - let c = photo(3); - for p in [&a, &b, &c] { - cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); - } - cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap(); - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true))); - assert!(m(&c, "stacked", "is", json!(false))); - let mut v = photo(4); - assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration"); - v.duration = Some(95.0); - assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120]))); - let mut d = photo(5); - assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color"))); - let mut s = (*d.develop).clone(); - s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0); - s.treatment = lightcraft_develop::Treatment::Bw; - d.develop = std::sync::Arc::new(s); - assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome"))); - assert!(m(&d, "treatment", "isNot", json!("color"))); - let mut s = (*d.develop).clone(); - s.crop = Default::default(); - s.crop.geometry.angle = 2.0; - d.develop = std::sync::Arc::new(s); - assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop"); - } - - /// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and - /// a crop changes its edges and its megapixels. Long / Short Edge are in pixels. - #[test] - fn size_rules_follow_orientation_and_crop() { - let cat = Catalog::new(); - let mut p = photo(1); // 6000 × 4000 - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000))); - assert!(m(&p, "megapixels", "is", json!(24))); - let mut s = (*p.develop).clone(); - s.orientation = lightcraft_geom::Orientation::Rotate90; - p.develop = std::sync::Arc::new(s.clone()); - assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000))); - // a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall - s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0); - p.develop = std::sync::Arc::new(s); - assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000))); - assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24"); - assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24))); - assert!(m(&p, "aspect", "isNot", json!("portrait"))); - let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); - assert!( - !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)) - ); - assert!(m(&empty, "megapixels", "is", json!(0))); - } - - /// Any Searchable Text also finds the state / province, the alt text, a person on a face and - /// a virtual copy's name, as it already finds the city and country. - #[test] - fn any_searchable_text_covers_new_fields() { - let cat = Catalog::new(); - let mut p = photo(1); - let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat); - for word in ["oregon", "kite", "ana", "bluish"] { - assert!(!m(&p, word), "{word}"); - } - p.meta.state = "Oregon".into(); - p.meta.alt_text = "A kite".into(); - p.meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: Some("Ana".into()), - description: None, - }]; - p.copy_name = Some("Bluish".into()); - for word in ["oregon", "kite", "ana", "bluish"] { - assert!(m(&p, word), "{word}"); - } - } - - /// Choice values have readable labels: the editor and summaries show "Public Domain" and - /// "Black & White", never ids like `publicDomain`; rules still store and accept the ids. - #[test] - fn choices_have_readable_labels() { - assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain")); - assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White")); - // own labels where a shared word would translate wrongly: "Color" is the colour noun (the - // Color panel) and "None" agrees with other nouns in gendered languages - assert_eq!(choice_label("treatment", "color"), Some("In Color")); - assert_eq!(choice_label("label", "none"), Some("No Label")); - assert_eq!(choice_label("flag", "pick"), Some("Picked")); - assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)")); - assert_eq!(choice_label("flag", "nope"), None); - assert_eq!(choice_label("rating", "pick"), None, "not a choice field"); - for (field, _, kind) in FIELDS { - let Kind::Choice(choices) = kind else { continue }; - for (id, label) in *choices { - assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own"); - assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}"); - } - } - let r = rs( - json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}), - ); - assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected"); - let mut p = photo(1); - p.meta.copyright_status = crate::CopyrightStatus::PublicDomain; - assert!(r.matches(&p, &Catalog::new()), "ids still match"); - } - - /// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every - /// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value - /// are yes. Anything else is reported, not silently taken as yes. - #[test] - fn yes_no_values() { - for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] { - assert_eq!(bool_value(&v), Some(true), "{v}"); - } - for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] { - assert_eq!(bool_value(&v), Some(false), "{v}"); - } - for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] { - assert_eq!(bool_value(&v), None, "{v}"); - } - assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No")); - let cat = Catalog::new(); - let p = photo(1); // unedited - assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no"); - assert!( - !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})) - .matches(&p, &cat) - ); - assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing"); - let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); - assert_eq!(r.describe(), "has edits is yes and cropped is no"); - let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})); - let messages: Vec = bad.check(&cat).into_iter().map(|p| p.message).collect(); - assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]); - } - - /// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero, - /// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means - /// "ever", so every dated photo is in it and none is outside it. - #[test] - fn in_the_last_survives_hostile_counts() { - set_now(Some("2026-09-01T00:00:00".into())); - let cat = Catalog::new(); - let p = photo(1); // captured 2026-08-14 - let m = |op: &str, n: serde_json::Value, unit: &str| { - rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat) - }; - for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] { - for op in ["inLast", "notInLast"] { - assert!(!m(op, n.clone(), "years"), "{op} {n}"); - } - } - for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] { - assert!(m("inLast", n.clone(), "years"), "{n} years is ever"); - assert!(!m("notInLast", n.clone(), "years"), "{n}"); - } - assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days")); - set_now(None); - } - - /// Every rule's value is checked against its field, so a rule that can't mean anything is - /// reported instead of quietly matching nothing (or everything). Each problem says which rule - /// it is about: "rule 2", or "rule 2.1" inside a group. - #[test] - fn values_are_checked_against_their_field() { - use crate::{Album, AlbumId, Op}; - let mut cat = Catalog::new(); - cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); - let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") }; - cat.apply(Op::AddAlbum { album: smart }).unwrap(); - cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap(); - let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat); - let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone())); - let bad = |rule: serde_json::Value, want: &str| { - let p = check(rule.clone()); - assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}"); - }; - let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value}); - // numbers - ok(r("rating", "gte", json!(3))); - ok(r("rating", "gte", json!("4"))); - ok(r("rating", "between", json!([1, 5]))); - ok(r("aperture", "lte", json!("f/4"))); - ok(r("focalLength", "is", json!("50mm"))); - bad(r("rating", "gte", json!("abc")), "needs a number"); - bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7"); - bad(r("iso", "between", json!([100])), "two"); - bad(r("iso", "between", json!("100")), "two"); - ok(r("shutterSpeed", "lte", json!("1/60"))); - bad(r("shutterSpeed", "lte", json!("fast")), "1/250"); - // dates - for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] { - ok(r("captureDate", "is", json!(d))); - } - ok(r("captureDate", "between", json!(["2026-01", "2026-12"]))); - for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] { - bad(r("captureDate", "after", d), "needs a date"); - } - bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second"); - bad(r("captureDate", "between", json!(["", "2026"])), "needs a date"); - ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"}))); - ok(r("captureDate", "inLast", json!(7))); - bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0"); - bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights"); - ok(r("captureDate", "isEmpty", json!(null))); - // choices: the ids, the flag's aliases and a colour label's custom name - ok(r("kind", "is", json!("raw"))); - ok(r("flag", "is", json!("picked"))); - ok(r("label", "is", json!("client"))); - bad(r("label", "is", json!("pink")), "pink"); - bad(r("kind", "isNot", json!("photo")), "photo"); - // text needs something to look for - ok(r("title", "contains", json!("kite"))); - ok(r("title", "isEmpty", json!(null))); - bad(r("title", "contains", json!(" ")), "something to look for"); - bad(r("keywords", "startsWith", json!("")), "something to look for"); - // albums: one that exists, plain or smart - ok(r("album", "is", json!(1))); - bad(r("album", "is", json!(999)), "no album 999"); - ok(r("album", "is", json!(2))); - // groups: an empty one is a mistake; problems inside one say where they are - let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); - assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); - let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]})) - .check(&cat); - assert_eq!(p.len(), 1); - assert_eq!(p[0].path, vec![1, 1]); - assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9"); - // an empty rule list at the top is "all photos" (or none, for any), not a mistake - assert!(rs(json!({"rules": []})).check(&cat).is_empty()); - } - - /// The check and the matcher agree. Units are any case, singular or plural, and missing means - /// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was - /// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's - /// custom name matches in any case, accents included. Empty text hints at the right operator. - #[test] - fn check_and_matcher_agree() { - use crate::Op; - set_now(Some("2026-09-01T00:00:00".into())); - let mut cat = Catalog::new(); - cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap(); - let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4 - let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})); - let valid = |r: &RuleSet| r.check(&cat).is_empty(); - for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] { - let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit})); - assert!(valid(&r) && r.matches(&p, &cat), "{unit}"); - } - let r = rule("captureDate", "inLast", json!({"n": 30})); - assert!(valid(&r) && r.matches(&p, &cat), "no unit is days"); - let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"})); - assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it"); - assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat)); - let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"})); - assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing"); - // rating: only a rule no rating could meet - for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] { - assert!(valid(&rule("rating", op, v.clone())), "{op} {v}"); - } - for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] { - assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}"); - } - // a custom label name, any case, accents too - let r = rule("label", "is", json!("été")); - assert!(valid(&r) && r.matches(&p, &cat)); - // "isn't" with nothing points at "isn't empty", the others at "is empty" - let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default(); - assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot")); - assert!(hint("contains").contains("“is empty”"), "{}", hint("contains")); - set_now(None); - } - - /// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between". - #[test] - fn album_rules_are_is_or_isnt() { - assert_eq!(field_kind("album"), Some(Kind::Album)); - let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect(); - assert_eq!(ops, ["is", "isNot"]); - let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]})); - assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`")); - } - - /// An Album rule names its album by id, written as a number or a numeric string, the same way - /// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be - /// tested. - #[test] - fn album_rule_ids() { - use crate::{Album, AlbumId, Op}; - assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3))); - assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3))); - assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3))); - for v in [json!(1.5), json!(-1), json!("x"), json!(null)] { - assert_eq!(album_rule_id(&v), None, "{v}"); - } - let mut cat = Catalog::new(); - cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap(); - let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat); - assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}"); - } - - /// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a - /// number field) always meant "isn't" to the matcher; upgrading says so, so they check again. - #[test] - fn old_album_operators_upgrade_to_isnt() { - let mut r = rs(json!({"rules": [ - {"field": "album", "op": "gte", "value": 1}, - {"field": "album", "op": "is", "value": 1}, - {"field": "rating", "op": "gte", "value": 3}, - {"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}} - ]})); - r.upgrade(); - let ops = serde_json::to_value(&r).unwrap().to_string(); - assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}"); - assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}"); - assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}"); - assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}"); - } - - /// Besides its English message (for agents), a problem names its field and the kind of issue, - /// so the editor can show "Title: needs something to look for" in any language. Every issue - /// has its own short text. - #[test] - fn problems_name_their_field_and_issue() { - let cat = Catalog::new(); - let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat); - let p = check(json!([{"field": "title", "op": "contains", "value": ""}])); - assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty)); - let p = check(json!([{"field": "title", "op": "isNot", "value": ""}])); - assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty); - // keywords and people say "are empty", and so does their hint - let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}])); - assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty)); - assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”")); - let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}])); - assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches)); - assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup)); - let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}])); - assert_eq!(p[0].issue, Issue::DatesReversed); - let p = check(json!([{"field": "album", "op": "is", "value": null}])); - assert_eq!(p[0].issue, Issue::ChooseAlbum); - let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect(); - assert!(texts.iter().all(|t| !t.is_empty())); - texts.sort_unstable(); - texts.dedup(); - assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); - } - - /// A rule date reads one way for the check and the matcher: a space works as the "T" - /// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions - /// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a - /// rule may too, after the seconds; anywhere else they are no date. - #[test] - fn rule_dates_have_one_reading() { - let cat = Catalog::new(); - let mut p = photo(1); - p.captured = Some("2026-10-01T10:00:00".into()); - let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]})); - for (op, v) in [ - ("is", json!("2026-10-01 10:00")), - ("is", json!(" 2026-10-01T10 ")), - ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])), - ("after", json!("2026-10-01 09:59")), - ] { - let r = rule(op, v.clone()); - assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); - } - // a capture time with a fraction and a zone, as imports write it - let mut zoned = photo(2); - zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into()); - for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] { - let r = rule("is", json!(v)); - assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}"); - } - assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty()); - for v in [ - "2026-8-14", - "2026-08-14T1", - "2026-08-14X10", - "26-08-14", - "2026-08-14T25:00", - "2026-08-14Z", - "2026-08-14 10:00-05:00", - "2026-08-14T10:00:00.", - "2026-08-14T10:00:00+2", - ] { - let p = rule("is", json!(v)).check(&cat); - assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); - } - assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string())); - assert_eq!(rule_date("2026"), Ok("2026".to_string())); - } - - /// The field menu shows every rule field once: at the top level or in exactly one group, - /// and [`FIELDS`] lists them in menu order. - #[test] - fn every_field_is_in_the_menu_exactly_once() { - let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect(); - let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect(); - assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order"); - for (label, group) in FIELD_GROUPS { - assert!(!group.is_empty(), "group {label} is empty"); - } - let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect(); - labels.dedup(); - assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique"); - } - - /// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the - /// dates, all the keyword fields. - #[test] - fn related_fields_share_a_group() { - let same = |fields: &[&str], group: &str| { - for f in fields { - assert_eq!(field_group(f), Some(group), "{f}"); - } - }; - same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File"); - same(&["album", "virtualCopy", "copyName", "stacked"], "Source"); - same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size"); - same(&["edited", "cropped", "treatment"], "Develop"); - same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); - same(&["captureDate", "importDate", "editDate"], "Date"); - same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); - same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description"); - same(&["location", "city", "state", "country", "hasGps"], "Location"); - for f in ["rating", "flag", "label", "text"] { - assert_eq!(field_group(f), None, "{f} stays at the top level"); - } - assert_eq!(field_group("nope"), None); - assert_eq!(field_label("filePath"), Some("File Path")); - assert_eq!(field_label("nope"), None); - } - - /// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo. - fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool { - let want = lower(value); - let path = match source { - Source::File { path } => path.replace('\\', "/"), - Source::Demo { .. } => String::new(), - }; - let want = want.replace('\\', "/"); - match op { - "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), - "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), - _ => text_op(op, &path, &want), - } - } - - #[test] - fn file_path_rules_match_the_old_strings_for_any_path() { - let paths = [ - "", - " ", - " /a/b.jpg \t", - "/Volumes/share/photos/Trip/IMG_0001.jpg", - "/Volumes/share/PHOTOS/trip/img_0001.JPG", - r"D:\Photos\Lake Como\IMG_0001.JPG", - r"d:/photos/lake como/img_0001.jpg", - r"\\nas\share\x.jpg", - "/photos/Straße/a.jpg", - "/photos/STRASSE/a.jpg", - "/photos/İstanbul/a.jpg", - "/photos/i̇stanbul/a.jpg", - "/photos/ΟΔΟΣ/a.jpg", - "/photos/οδος/a.jpg", - "/photos/ΟΔΌΣ/a.jpg", - "/photos/日本/東京.jpg", - "/p/\u{0B}x\u{0B}", - "/p/\u{85}x\u{85}", - "a", - ]; - let wants = [ - "", - " ", - "a", - "A", - "/", - r"\", - r"\photos\", - "/photos/", - "photos", - "PHOTOS", - "Lake Como", - " lake como ", - "lake como", - r"d:\photos\lake", - "IMG_0001.JPG", - ".jpg", - "straße", - "STRASSE", - "strasse", - "İstanbul", - "i̇stanbul", - "istanbul", - "ΟΔΟΣ", - "οδος", - "οδοσ", - "ΟΔΌΣ", - "東京", - "日本", - "x", - "\u{0B}x", - "/a/b.jpg", - "/a/b.jpg \t", - "\u{85}x", - ]; - let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"]; - let mut values: Vec = wants.iter().map(|w| json!(w)).collect(); - values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]); - let mut sources: Vec = paths.iter().map(|p| Source::File { path: (*p).into() }).collect(); - sources.push(Source::Demo { scene: 0 }); - for source in &sources { - for op in ops { - for value in &values { - assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}"); - } - } - } - } - - #[test] - fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() { - let mut p = photo(1); - p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() }; - let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]})); - assert!(r.matches(&p, &Catalog::default())); - } - - /// Every piece of a long path, in both cases and both slash directions, wherever it starts - /// (the search skips eight bytes at a time, so starts and ends at every offset matter). - #[test] - fn file_path_contains_finds_every_piece_of_a_long_path() { - let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG"; - let source = Source::File { path: path.into() }; - let shout = |s: &str| s.to_uppercase(); - let swap = |s: &str| s.replace('\\', "/"); - for from in 0..path.len() { - for to in from + 1..=path.len() { - let piece = &path[from..to]; - for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] { - let value = json!(want); - for op in ["contains", "notContains"] { - assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}"); - } - } - } - } - } -} +mod tests; diff --git a/crates/catalog/src/rules/tests.rs b/crates/catalog/src/rules/tests.rs new file mode 100644 index 000000000..8d9d649b9 --- /dev/null +++ b/crates/catalog/src/rules/tests.rs @@ -0,0 +1,776 @@ +use super::*; +use crate::{ColorLabel, Flag, PhotoId, Source}; +use serde_json::json; + +fn photo(id: u64) -> Photo { + let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00"); + p.rating = 4; + p.flag = Flag::Pick; + p.label = Some(ColorLabel::Red); + p.captured = Some("2026-08-14T18:30:00".into()); + p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()]; + p.meta.camera = "Model X2".into(); + p.meta.iso = Some(1600); + p.meta.aperture = Some(2.8); + p +} + +fn rs(v: serde_json::Value) -> RuleSet { + serde_json::from_value(v).unwrap() +} + +#[test] +fn f32_boundary_values_match_gte_lte() { + let cat = Catalog::new(); + let matches = |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat); + for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] { + let mut p = photo(1); + p.meta.aperture = Some(v); + p.meta.focal_mm = Some(v); + let w = v.to_string().parse::().unwrap(); + for field in ["aperture", "focalLength"] { + assert!(matches(&p, field, "gte", w), "{field} gte {w}"); + assert!(matches(&p, field, "lte", w), "{field} lte {w}"); + assert!(!matches(&p, field, "gt", w), "{field} gt {w}"); + assert!(!matches(&p, field, "lt", w), "{field} lt {w}"); + } + } +} + +#[test] +fn fields_ops_and_groups() { + let cat = Catalog::new(); + let p = photo(1); + let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}"); + let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}"); + yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]})); + no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]})); + yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]})); + no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]})); + yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]})); + yes(json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]})); + no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]})); + yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); + // file path: the whole string (spaces included), either separator, case-insensitive + let mut fp = photo(3); + fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() }; + let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); + no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path + yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); + yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); + yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); + yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]})); + yes( + json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}), + ); + yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]})); + no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]})); + yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]})); + // copyright status (unknown until set) + yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]})); + no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]})); + let mut pd = photo(2); + pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain; + assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat)); + assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat)); + // nested: rating ≥ 4 and (label is blue or keywords contain food) + yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]})); + // in the last N days, relative to now + set_now(Some("2026-09-01T00:00:00".into())); + yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]})); + no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]})); + yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]})); + set_now(None); + // an empty rule list: all → everything, any → nothing + yes(json!({"rules": []})); + no(json!({"match": "any", "rules": []})); +} + +#[test] +fn problems_and_description() { + let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]})); + let messages: Vec = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); + let r = rs( + json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}), + ); + assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks"); + // every field has a kind with operators + for (f, _, k) in FIELDS { + assert!(!ops_for(*k).is_empty(), "{f}"); + } +} + +/// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path +/// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one. +#[test] +fn keyword_count_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // travel|italy|rome, food + let m = + |p: &Photo, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat); + assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); + assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); + p.meta.keywords.push("Food".into()); + assert!(m(&p, "is", json!(2)), "Food and food are one keyword"); + p.meta.keywords.clear(); + assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2))); + assert_eq!(field_group("keywordCount"), Some("Keywords & People")); +} + +/// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed +/// faces are not people; People Count counts each person once. +#[test] +fn people_rules() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let cat = Catalog::new(); + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut p = photo(1); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0))); + p.meta.regions = vec![ + region(Some("Ana Lima"), RegionKind::Face), + region(Some(" ana lima "), RegionKind::Face), + region(Some("Rex"), RegionKind::Pet), + region(None, RegionKind::Face), + region(Some("Bo"), RegionKind::Face), + ]; + assert!(m(&p, "person", "contains", json!("ana"))); + assert!(m(&p, "person", "is", json!("ANA LIMA"))); + assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person"); + assert!(m(&p, "person", "notContains", json!("rex"))); + assert!(m(&p, "person", "isNotEmpty", json!(null))); + assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face"); + assert_eq!(field_group("person"), Some("Keywords & People")); +} + +/// Shutter Speed compares exposure times in seconds, written as the camera shows them: a +/// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125". +#[test] +fn shutter_speed_rules() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = + |p: &Photo, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat); + assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); + p.meta.shutter = "1/250".into(); + assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); + assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01))); + assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing"); + p.meta.shutter = "2\"".into(); + assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2))); + assert_eq!(field_group("shutterSpeed"), Some("Camera Info")); +} + +#[test] +fn shutter_seconds_parses_camera_notation() { + for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] { + assert_eq!(crate::parse_shutter_seconds(s), want, "{s}"); + } + for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] { + assert_eq!(crate::parse_shutter_seconds(s), None, "{s}"); + } +} + +/// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State / +/// Province and Country are text fields of their own (Location still matches all of them). +#[test] +fn file_source_description_and_location_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // IMG_0042.CR2 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2"))); + assert!(!m(&p, "extension", "is", json!("jpg"))); + p.file_name = "README".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension"); + p.file_name = "archive.tar.gz".into(); + assert!(m(&p, "extension", "is", json!("gz"))); + p.file_name = ".hidden".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension"); + assert!(m(&p, "copyName", "isEmpty", json!(null))); + p.copy_name = Some("Black and white".into()); + assert!(m(&p, "copyName", "contains", json!("black"))); + p.meta.alt_text = "A red kite over a hill".into(); + assert!(m(&p, "altText", "contains", json!("kite"))); + p.meta.city = "Porto".into(); + p.meta.state = "Porto District".into(); + p.meta.country = "Portugal".into(); + assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal"))); + assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city"); + assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country"); +} + +/// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment. +#[test] +fn source_file_and_develop_rules() { + use crate::{Op, Stack, StackId}; + let mut cat = Catalog::new(); + let a = photo(1); + let b = photo(2); + let c = photo(3); + for p in [&a, &b, &c] { + cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + } + cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap(); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true))); + assert!(m(&c, "stacked", "is", json!(false))); + let mut v = photo(4); + assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration"); + v.duration = Some(95.0); + assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120]))); + let mut d = photo(5); + assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0); + s.treatment = lightcraft_develop::Treatment::Bw; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome"))); + assert!(m(&d, "treatment", "isNot", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop = Default::default(); + s.crop.geometry.angle = 2.0; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop"); +} + +/// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and +/// a crop changes its edges and its megapixels. Long / Short Edge are in pixels. +#[test] +fn size_rules_follow_orientation_and_crop() { + let cat = Catalog::new(); + let mut p = photo(1); // 6000 × 4000 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(24))); + let mut s = (*p.develop).clone(); + s.orientation = lightcraft_geom::Orientation::Rotate90; + p.develop = std::sync::Arc::new(s.clone()); + assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000))); + // a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0); + p.develop = std::sync::Arc::new(s); + assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24"); + assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24))); + assert!(m(&p, "aspect", "isNot", json!("portrait"))); + let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); + assert!(!m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0))); + assert!(m(&empty, "megapixels", "is", json!(0))); +} + +/// Any Searchable Text also finds the state / province, the alt text, a person on a face and +/// a virtual copy's name, as it already finds the city and country. +#[test] +fn any_searchable_text_covers_new_fields() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(!m(&p, word), "{word}"); + } + p.meta.state = "Oregon".into(); + p.meta.alt_text = "A kite".into(); + p.meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some("Ana".into()), + description: None, + }]; + p.copy_name = Some("Bluish".into()); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(m(&p, word), "{word}"); + } +} + +/// Choice values have readable labels: the editor and summaries show "Public Domain" and +/// "Black & White", never ids like `publicDomain`; rules still store and accept the ids. +#[test] +fn choices_have_readable_labels() { + assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain")); + assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White")); + // own labels where a shared word would translate wrongly: "Color" is the colour noun (the + // Color panel) and "None" agrees with other nouns in gendered languages + assert_eq!(choice_label("treatment", "color"), Some("In Color")); + assert_eq!(choice_label("label", "none"), Some("No Label")); + assert_eq!(choice_label("flag", "pick"), Some("Picked")); + assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)")); + assert_eq!(choice_label("flag", "nope"), None); + assert_eq!(choice_label("rating", "pick"), None, "not a choice field"); + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, label) in *choices { + assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own"); + assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}"); + } + } + let r = rs( + json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}), + ); + assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected"); + let mut p = photo(1); + p.meta.copyright_status = crate::CopyrightStatus::PublicDomain; + assert!(r.matches(&p, &Catalog::new()), "ids still match"); +} + +/// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every +/// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value +/// are yes. Anything else is reported, not silently taken as yes. +#[test] +fn yes_no_values() { + for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] { + assert_eq!(bool_value(&v), Some(true), "{v}"); + } + for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] { + assert_eq!(bool_value(&v), Some(false), "{v}"); + } + for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] { + assert_eq!(bool_value(&v), None, "{v}"); + } + assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No")); + let cat = Catalog::new(); + let p = photo(1); // unedited + assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no"); + assert!( + !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})).matches(&p, &cat) + ); + assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing"); + let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); + assert_eq!(r.describe(), "has edits is yes and cropped is no"); + let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})); + let messages: Vec = bad.check(&cat).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]); +} + +/// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero, +/// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means +/// "ever", so every dated photo is in it and none is outside it. +#[test] +fn in_the_last_survives_hostile_counts() { + set_now(Some("2026-09-01T00:00:00".into())); + let cat = Catalog::new(); + let p = photo(1); // captured 2026-08-14 + let m = |op: &str, n: serde_json::Value, unit: &str| { + rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat) + }; + for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] { + for op in ["inLast", "notInLast"] { + assert!(!m(op, n.clone(), "years"), "{op} {n}"); + } + } + for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] { + assert!(m("inLast", n.clone(), "years"), "{n} years is ever"); + assert!(!m("notInLast", n.clone(), "years"), "{n}"); + } + assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days")); + set_now(None); +} + +/// Every rule's value is checked against its field, so a rule that can't mean anything is +/// reported instead of quietly matching nothing (or everything). Each problem says which rule +/// it is about: "rule 2", or "rule 2.1" inside a group. +#[test] +fn values_are_checked_against_their_field() { + use crate::{Album, AlbumId, Op}; + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); + let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") }; + cat.apply(Op::AddAlbum { album: smart }).unwrap(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap(); + let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat); + let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone())); + let bad = |rule: serde_json::Value, want: &str| { + let p = check(rule.clone()); + assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}"); + }; + let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value}); + // numbers + ok(r("rating", "gte", json!(3))); + ok(r("rating", "gte", json!("4"))); + ok(r("rating", "between", json!([1, 5]))); + ok(r("aperture", "lte", json!("f/4"))); + ok(r("focalLength", "is", json!("50mm"))); + bad(r("rating", "gte", json!("abc")), "needs a number"); + bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7"); + bad(r("iso", "between", json!([100])), "two"); + bad(r("iso", "between", json!("100")), "two"); + ok(r("shutterSpeed", "lte", json!("1/60"))); + bad(r("shutterSpeed", "lte", json!("fast")), "1/250"); + // dates + for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] { + ok(r("captureDate", "is", json!(d))); + } + ok(r("captureDate", "between", json!(["2026-01", "2026-12"]))); + for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] { + bad(r("captureDate", "after", d), "needs a date"); + } + bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second"); + bad(r("captureDate", "between", json!(["", "2026"])), "needs a date"); + ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"}))); + ok(r("captureDate", "inLast", json!(7))); + bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0"); + bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights"); + ok(r("captureDate", "isEmpty", json!(null))); + // choices: the ids, the flag's aliases and a colour label's custom name + ok(r("kind", "is", json!("raw"))); + ok(r("flag", "is", json!("picked"))); + ok(r("label", "is", json!("client"))); + bad(r("label", "is", json!("pink")), "pink"); + bad(r("kind", "isNot", json!("photo")), "photo"); + // text needs something to look for + ok(r("title", "contains", json!("kite"))); + ok(r("title", "isEmpty", json!(null))); + bad(r("title", "contains", json!(" ")), "something to look for"); + bad(r("keywords", "startsWith", json!("")), "something to look for"); + // albums: one that exists, plain or smart + ok(r("album", "is", json!(1))); + bad(r("album", "is", json!(999)), "no album 999"); + ok(r("album", "is", json!(2))); + // groups: an empty one is a mistake; problems inside one say where they are + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); + assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]})) + .check(&cat); + assert_eq!(p.len(), 1); + assert_eq!(p[0].path, vec![1, 1]); + assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9"); + // an empty rule list at the top is "all photos" (or none, for any), not a mistake + assert!(rs(json!({"rules": []})).check(&cat).is_empty()); +} + +/// The check and the matcher agree. Units are any case, singular or plural, and missing means +/// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was +/// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's +/// custom name matches in any case, accents included. Empty text hints at the right operator. +#[test] +fn check_and_matcher_agree() { + use crate::Op; + set_now(Some("2026-09-01T00:00:00".into())); + let mut cat = Catalog::new(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap(); + let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4 + let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})); + let valid = |r: &RuleSet| r.check(&cat).is_empty(); + for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] { + let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit})); + assert!(valid(&r) && r.matches(&p, &cat), "{unit}"); + } + let r = rule("captureDate", "inLast", json!({"n": 30})); + assert!(valid(&r) && r.matches(&p, &cat), "no unit is days"); + let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"})); + assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it"); + assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat)); + let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"})); + assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing"); + // rating: only a rule no rating could meet + for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] { + assert!(valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] { + assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + // a custom label name, any case, accents too + let r = rule("label", "is", json!("été")); + assert!(valid(&r) && r.matches(&p, &cat)); + // "isn't" with nothing points at "isn't empty", the others at "is empty" + let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default(); + assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot")); + assert!(hint("contains").contains("“is empty”"), "{}", hint("contains")); + set_now(None); +} + +/// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between". +#[test] +fn album_rules_are_is_or_isnt() { + assert_eq!(field_kind("album"), Some(Kind::Album)); + let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect(); + assert_eq!(ops, ["is", "isNot"]); + let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]})); + assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`")); +} + +/// An Album rule names its album by id, written as a number or a numeric string, the same way +/// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be +/// tested. +#[test] +fn album_rule_ids() { + use crate::{Album, AlbumId, Op}; + assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3))); + for v in [json!(1.5), json!(-1), json!("x"), json!(null)] { + assert_eq!(album_rule_id(&v), None, "{v}"); + } + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap(); + let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat); + assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}"); +} + +/// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a +/// number field) always meant "isn't" to the matcher; upgrading says so, so they check again. +#[test] +fn old_album_operators_upgrade_to_isnt() { + let mut r = rs(json!({"rules": [ + {"field": "album", "op": "gte", "value": 1}, + {"field": "album", "op": "is", "value": 1}, + {"field": "rating", "op": "gte", "value": 3}, + {"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}} + ]})); + r.upgrade(); + let ops = serde_json::to_value(&r).unwrap().to_string(); + assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}"); + assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}"); +} + +/// Besides its English message (for agents), a problem names its field and the kind of issue, +/// so the editor can show "Title: needs something to look for" in any language. Every issue +/// has its own short text. +#[test] +fn problems_name_their_field_and_issue() { + let cat = Catalog::new(); + let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat); + let p = check(json!([{"field": "title", "op": "contains", "value": ""}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty)); + let p = check(json!([{"field": "title", "op": "isNot", "value": ""}])); + assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty); + // keywords and people say "are empty", and so does their hint + let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}])); + assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty)); + assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”")); + let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches)); + assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup)); + let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}])); + assert_eq!(p[0].issue, Issue::DatesReversed); + let p = check(json!([{"field": "album", "op": "is", "value": null}])); + assert_eq!(p[0].issue, Issue::ChooseAlbum); + let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect(); + assert!(texts.iter().all(|t| !t.is_empty())); + texts.sort_unstable(); + texts.dedup(); + assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); +} + +/// A rule date reads one way for the check and the matcher: a space works as the "T" +/// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions +/// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a +/// rule may too, after the seconds; anywhere else they are no date. +#[test] +fn rule_dates_have_one_reading() { + let cat = Catalog::new(); + let mut p = photo(1); + p.captured = Some("2026-10-01T10:00:00".into()); + let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]})); + for (op, v) in [ + ("is", json!("2026-10-01 10:00")), + ("is", json!(" 2026-10-01T10 ")), + ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])), + ("after", json!("2026-10-01 09:59")), + ] { + let r = rule(op, v.clone()); + assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); + } + // a capture time with a fraction and a zone, as imports write it + let mut zoned = photo(2); + zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into()); + for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] { + let r = rule("is", json!(v)); + assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}"); + } + assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty()); + for v in [ + "2026-8-14", + "2026-08-14T1", + "2026-08-14X10", + "26-08-14", + "2026-08-14T25:00", + "2026-08-14Z", + "2026-08-14 10:00-05:00", + "2026-08-14T10:00:00.", + "2026-08-14T10:00:00+2", + ] { + let p = rule("is", json!(v)).check(&cat); + assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); + } + assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string())); + assert_eq!(rule_date("2026"), Ok("2026".to_string())); +} + +/// The field menu shows every rule field once: at the top level or in exactly one group, +/// and [`FIELDS`] lists them in menu order. +#[test] +fn every_field_is_in_the_menu_exactly_once() { + let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect(); + let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect(); + assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order"); + for (label, group) in FIELD_GROUPS { + assert!(!group.is_empty(), "group {label} is empty"); + } + let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect(); + labels.dedup(); + assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique"); +} + +/// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the +/// dates, all the keyword fields. +#[test] +fn related_fields_share_a_group() { + let same = |fields: &[&str], group: &str| { + for f in fields { + assert_eq!(field_group(f), Some(group), "{f}"); + } + }; + same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File"); + same(&["album", "virtualCopy", "copyName", "stacked"], "Source"); + same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size"); + same(&["edited", "cropped", "treatment"], "Develop"); + same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); + same(&["captureDate", "importDate", "editDate"], "Date"); + same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); + same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description"); + same(&["location", "city", "state", "country", "hasGps"], "Location"); + for f in ["rating", "flag", "label", "text"] { + assert_eq!(field_group(f), None, "{f} stays at the top level"); + } + assert_eq!(field_group("nope"), None); + assert_eq!(field_label("filePath"), Some("File Path")); + assert_eq!(field_label("nope"), None); +} + +/// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo. +fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool { + let want = lower(value); + let path = match source { + Source::File { path } => path.replace('\\', "/"), + Source::Demo { .. } => String::new(), + }; + let want = want.replace('\\', "/"); + match op { + "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), + "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), + _ => text_op(op, &path, &want), + } +} + +#[test] +fn file_path_rules_match_the_old_strings_for_any_path() { + let paths = [ + "", + " ", + " /a/b.jpg \t", + "/Volumes/share/photos/Trip/IMG_0001.jpg", + "/Volumes/share/PHOTOS/trip/img_0001.JPG", + r"D:\Photos\Lake Como\IMG_0001.JPG", + r"d:/photos/lake como/img_0001.jpg", + r"\\nas\share\x.jpg", + "/photos/Straße/a.jpg", + "/photos/STRASSE/a.jpg", + "/photos/İstanbul/a.jpg", + "/photos/i̇stanbul/a.jpg", + "/photos/ΟΔΟΣ/a.jpg", + "/photos/οδος/a.jpg", + "/photos/ΟΔΌΣ/a.jpg", + "/photos/日本/東京.jpg", + "/p/\u{0B}x\u{0B}", + "/p/\u{85}x\u{85}", + "a", + ]; + let wants = [ + "", + " ", + "a", + "A", + "/", + r"\", + r"\photos\", + "/photos/", + "photos", + "PHOTOS", + "Lake Como", + " lake como ", + "lake como", + r"d:\photos\lake", + "IMG_0001.JPG", + ".jpg", + "straße", + "STRASSE", + "strasse", + "İstanbul", + "i̇stanbul", + "istanbul", + "ΟΔΟΣ", + "οδος", + "οδοσ", + "ΟΔΌΣ", + "東京", + "日本", + "x", + "\u{0B}x", + "/a/b.jpg", + "/a/b.jpg \t", + "\u{85}x", + ]; + let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"]; + let mut values: Vec = wants.iter().map(|w| json!(w)).collect(); + values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]); + let mut sources: Vec = paths.iter().map(|p| Source::File { path: (*p).into() }).collect(); + sources.push(Source::Demo { scene: 0 }); + for source in &sources { + for op in ops { + for value in &values { + assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}"); + } + } + } +} + +#[test] +fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() { + let mut p = photo(1); + p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() }; + let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]})); + assert!(r.matches(&p, &Catalog::default())); +} + +/// Every piece of a long path, in both cases and both slash directions, wherever it starts +/// (the search skips eight bytes at a time, so starts and ends at every offset matter). +#[test] +fn file_path_contains_finds_every_piece_of_a_long_path() { + let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG"; + let source = Source::File { path: path.into() }; + let shout = |s: &str| s.to_uppercase(); + let swap = |s: &str| s.replace('\\', "/"); + for from in 0..path.len() { + for to in from + 1..=path.len() { + let piece = &path[from..to]; + for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] { + let value = json!(want); + for op in ["contains", "notContains"] { + assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}"); + } + } + } + } +} diff --git a/crates/catalog/src/tests_repair.rs b/crates/catalog/src/tests_repair.rs index 493c89544..9feb038a5 100644 --- a/crates/catalog/src/tests_repair.rs +++ b/crates/catalog/src/tests_repair.rs @@ -108,6 +108,74 @@ fn folder_loops_and_missing_parents_move_to_the_top() { let _ = c.apply(Op::MoveAlbum { id: AlbumId(11), parent: Some(AlbumId(10)) }); } +/// Check the postcondition needed by every consumer that follows parent folders. This inspects +/// the repaired catalog, not its cache or a second implementation of the repair algorithm. +fn assert_all_album_chains_reach_roots(c: &Catalog) { + for album in c.albums.values() { + let mut seen = std::collections::HashSet::new(); + let mut current = album; + while let Some(parent) = current.parent { + assert!(seen.insert(current.id), "album {} still reaches a cycle", album.id.0); + current = c.albums.get(&parent).expect("every retained parent exists"); + assert!(current.folder, "every retained parent is a folder"); + } + } +} + +#[test] +fn a_child_visited_before_a_folder_cycle_keeps_its_content_and_gets_a_rooted_chain() { + let (c, ids) = sound(); + let (mut c, report) = load_edited(&c, |v| { + // BTreeMap order visits10 first: cached Bad11/12 used to be skipped without repair. + v["albums"]["10"] = serde_json::to_value(album(10, Some(11), false, vec![ids[2]])).unwrap(); + v["albums"]["11"] = serde_json::to_value(album(11, Some(12), true, vec![])).unwrap(); + v["albums"]["12"] = serde_json::to_value(album(12, Some(11), true, vec![])).unwrap(); + }); + assert_all_album_chains_reach_roots(&c); + assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(11)), "the child remains in its actual folder"); + assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]], "repair preserves the child's photos"); + assert_eq!(c.album(AlbumId(11)).unwrap().parent, None, "one cycle edge is repaired"); + assert_eq!(c.album(AlbumId(12)).unwrap().parent, Some(AlbumId(11)), "the other folder edge is retained"); + assert!(report.repaired.iter().any(|message| message.starts_with("album 11:")), "{:?}", report.repaired); + assert!(c.repair().is_empty(), "a second repair is idempotent"); +} + +#[test] +fn a_child_visited_before_a_folder_with_a_missing_parent_does_not_hide_the_bad_ancestor() { + let (c, ids) = sound(); + let (mut c, report) = load_edited(&c, |v| { + v["albums"]["10"] = serde_json::to_value(album(10, Some(11), false, vec![ids[2]])).unwrap(); + v["albums"]["11"] = serde_json::to_value(album(11, Some(999), true, vec![])).unwrap(); + }); + assert_all_album_chains_reach_roots(&c); + assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(11))); + assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]]); + assert_eq!(c.album(AlbumId(11)).unwrap().parent, None, "repair the missing-parent edge itself"); + assert!(report.repaired.iter().any(|message| message.starts_with("album 11:")), "{:?}", report.repaired); + assert!(c.repair().is_empty()); +} + +#[test] +fn a_long_valid_folder_chain_is_retained_when_its_child_is_visited_first() { + let (c, ids) = sound(); + let (mut c, report) = load_edited(&c, |v| { + v["albums"]["10"] = serde_json::to_value(album(10, Some(100), false, vec![ids[2]])).unwrap(); + for id in 100..612 { + v["albums"][id.to_string()] = serde_json::to_value(album(id, Some(id + 1), true, vec![])).unwrap(); + } + v["albums"]["612"] = serde_json::to_value(album(612, None, true, vec![])).unwrap(); + }); + assert_all_album_chains_reach_roots(&c); + assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(100))); + assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]]); + for id in 100..612 { + assert_eq!(c.album(AlbumId(id)).unwrap().parent, Some(AlbumId(id + 1)), "valid edge{id} remains"); + } + assert_eq!(c.album(AlbumId(612)).unwrap().parent, None); + assert!(!report.repaired.iter().any(|message| message.starts_with("album ")), "valid parents must not be repaired: {:?}", report.repaired); + assert!(c.repair().is_empty()); +} + /// Id counters left at the top (or behind the ids in use) by a damaged file: allocating no longer /// overflows (a panic in debug builds) and never hands out an id already in use. #[test] diff --git a/crates/denoise-core/Cargo.toml b/crates/denoise-core/Cargo.toml index b65f90ae4..d0a0b10c1 100644 --- a/crates/denoise-core/Cargo.toml +++ b/crates/denoise-core/Cargo.toml @@ -24,3 +24,4 @@ web-time = { workspace = true } [dev-dependencies] proptest = { workspace = true } +dhat = { workspace = true } diff --git a/crates/denoise-core/src/product.rs b/crates/denoise-core/src/product.rs index 5a607c8f4..c33b1d4c2 100644 --- a/crates/denoise-core/src/product.rs +++ b/crates/denoise-core/src/product.rs @@ -9,7 +9,7 @@ //! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's //! content, the model, this code's version, the settings it depends on); a file with another key is stale. -use std::io::{Read, Write}; +use std::io::{Read, Seek, Write}; use std::path::Path; use lightcraft_raster::Rgb32f; @@ -216,6 +216,10 @@ fn read_start(r: &mut impl Read) -> Result { r.read_exact(&mut e)?; let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize; let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]); + // a strip never compresses to nothing (it has at least one row, so at least one deflated pixel) + if l == 0 { + return Err(ProductError::Format("a strip is empty".into())); + } // a strip never compresses to much more than its raw size if l > 12 * h.strip * h.width + 4096 { return Err(ProductError::Format("a strip is larger than it can be".into())); @@ -305,7 +309,6 @@ pub fn read_window(path: &Path, key: &str, window: Option, factor: usize } // the picture rows and columns the blocks cover let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) }; - let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? }; let lut = f16_table(); let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 { let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0)); @@ -318,6 +321,21 @@ pub fn read_window(path: &Path, key: &str, window: Option, factor: usize let strips: Vec = (0..table.len()).filter(|&s| wanted(s)).collect(); // skip the strips before the first one wanted let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum(); + // The output is reserved only after the wanted strips' compressed bytes are present and match their checksums: + // a malformed cache whose strips are empty, damaged or short are rejected here, before the (large) result is + // allocated, not after. Reading them twice is cheap next to decoding them, and the fold below re-seeks here. + let strips_at = f.stream_position()?; + f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?; + for &s in &strips { + let Some(&(len, expected)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) }; + let mut packed = allocate(len, 0u8)?; + f.read_exact(&mut packed)?; + if crc(&packed) != expected { + return Err(ProductError::Format(format!("strip {s} is damaged"))); + } + } + let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? }; + f.seek(std::io::SeekFrom::Start(strips_at))?; f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?; for group in strips.chunks(GROUP) { let mut packed = Vec::with_capacity(group.len()); @@ -422,6 +440,21 @@ mod tests { let _ = std::fs::remove_file(p); } + #[test] + fn an_empty_strip_entry_is_rejected_by_the_header() { + let p = temp("empty-strip"); + let header = serde_json::to_vec(&Header { width: 16, height: 1, strip: STRIP_ROWS, key: "k".into() }).unwrap(); + let mut bytes = Vec::from(MAGIC.as_slice()); + bytes.extend_from_slice(&[VERSION, 0, 0, 0]); + bytes.extend_from_slice(&(header.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&header); + // one strip with a zero-length entry: it has a row, so its deflated bytes are never empty + bytes.extend_from_slice(&[0; 8]); + std::fs::write(&p, bytes).unwrap(); + assert!(matches!(read_header_at(&p), Err(ProductError::Format(why)) if why.contains("empty"))); + let _ = std::fs::remove_file(p); + } + fn temp(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id())) } diff --git a/crates/denoise-core/tests/product_heap.rs b/crates/denoise-core/tests/product_heap.rs new file mode 100644 index 000000000..40c170861 --- /dev/null +++ b/crates/denoise-core/tests/product_heap.rs @@ -0,0 +1,50 @@ +//! Peak heap of a `read_window` that must reject a product's strips (`crates/denoise-core/src/product.rs`), measured +//! with dhat's testing mode. The result is allocated only after the wanted strips' checksums are verified, so a cache +//! whose compressed bytes are damaged or absent fails without first reserving the (multi-megabyte) output. One test +//! per binary (dhat's profiler is global). + +use lightcraft_denoise_core::product::read_window; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn temp(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id())) +} + +/// A 2000 × 1024 product of key `k` (a 24 MB result): its 8 strips each claim 8 bytes that do not match their +/// checksum, and the file is exactly the length its header and table declare. A 24 MB peak means the output was +/// reserved; the cap here bounds the header, the table and the strips' own bytes. +fn damaged_product() -> Vec { + let header = br#"{"width":2000,"height":1024,"strip":128,"key":"k"}"#; + const STRIP_LEN: usize = 8; + let mut bytes = Vec::from(b"LCDN".as_slice()); + bytes.extend_from_slice(&[1, 0, 0, 0]); + bytes.extend_from_slice(&(header.len() as u32).to_le_bytes()); + bytes.extend_from_slice(header); + let expected_len = 12 + header.len() + 8 * 8 + 8 * STRIP_LEN; + for _ in 0..8 { + // 8 claimed bytes whose recorded checksum does not match theirs (0xAA bytes do not have this CRC) + bytes.extend_from_slice(&(STRIP_LEN as u32).to_le_bytes()); + bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + } + for _ in 0..8 { + bytes.extend_from_slice(&[0xAAu8; STRIP_LEN]); + } + assert_eq!(bytes.len(), expected_len, "the crafted file lies about its length"); + bytes +} + +#[test] +fn a_damaged_product_is_rejected_before_its_output_is_allocated() { + let bytes = damaged_product(); + let p = temp("damaged-product-heap"); + std::fs::write(&p, &bytes).unwrap(); + let _profiler = dhat::Profiler::builder().testing().build(); + let why = read_window(&p, "k", None, 1, None).err().unwrap().to_string(); + let peak = dhat::HeapStats::get().max_bytes; + let _ = std::fs::remove_file(p); + assert!(why.contains("damaged"), "{why}"); + eprintln!("peak heap {peak} bytes"); + assert!(peak < 4 << 20, "peak heap {peak} bytes: the output was reserved before the strips were checked"); +} diff --git a/crates/denoise/src/reference.rs b/crates/denoise/src/reference.rs index ef58b5b9c..488e9a7b7 100644 --- a/crates/denoise/src/reference.rs +++ b/crates/denoise/src/reference.rs @@ -4,6 +4,12 @@ use crate::net::{Net, NetError, Op, TensorId}; use crate::run::{Error, TileRunner}; +/// The tensors' size cap: the same one the real runner's buffers share (`crates/denoise/src/cpu.rs`). A hostile `Net` +/// only ever has to satisfy the final shape: a `ConvT2` doubles the sides and a `MaxPool` halves them, so a small +/// accepted graph can zoom its intermediates far out and back. Every tensor is counted here, and a run whose layers +/// would retain more than one real tile's whole working set is refused before the memory behind it is reserved. +const MAX_VALUES: usize = 128 * 1024 * 1024; + /// A tensor in `[channels][side][side]` order. struct T { channels: usize, @@ -11,12 +17,36 @@ struct T { data: Vec, } +/// A tensor's plain float count, in bounds. +fn count(channels: usize, side: usize) -> Result { + channels + .checked_mul(side) + .and_then(|n| n.checked_mul(side)) + .filter(|&n| n > 0 && n <= MAX_VALUES) + .ok_or_else(|| NetError::Invalid("a tensor has no size or is too large".into())) +} + +/// A zeroed tensor, keeping the memory the run keeps alive in bounds (`tensors` retains every layer's result). +fn tensor(channels: usize, side: usize, total: &mut usize) -> Result, NetError> { + let n = count(channels, side)?; + *total = (*total).saturating_add(n); + if *total > MAX_VALUES { + return Err(NetError::Invalid("the tile needs too much working memory".into())); + } + let mut data = Vec::new(); + data.try_reserve_exact(n).map_err(|_| NetError::Invalid("not enough memory for a reference tile".into()))?; + data.resize(n, 0.0); + Ok(data) +} + /// Run `net` on `input` (`in_channels` planes of `tile × tile`). pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> { if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile { return Err(NetError::Invalid("the tile does not fit the network".into())); } - let mut tensors: Vec = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }]; + let input = input.to_vec(); + let mut total = input.len(); + let mut tensors = vec![T { channels: net.in_channels, side: tile, data: input }]; let get = |tensors: &[T], id: TensorId| -> Result { if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) } }; @@ -27,7 +57,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> let b = c.src2.map(|s| get(&tensors, s)).transpose()?; let side = tensors[a].side; let pad = (c.k - 1) / 2; - let mut data = vec![0f32; c.cout * side * side]; + let mut data = tensor(c.cout, side, &mut total)?; // the input channels in order: the first source's, then the second's let planes: Vec<&[f32]> = std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect(); @@ -56,7 +86,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> let a = get(&tensors, c.src)?; let side = tensors[a].side; let out_side = 2 * side; - let mut data = vec![0f32; c.cout * out_side * out_side]; + let mut data = tensor(c.cout, out_side, &mut total)?; for co in 0..c.cout { for y in 0..side { for x in 0..side { @@ -77,7 +107,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> Op::MaxPool2 { src } => { let a = get(&tensors, *src)?; let (channels, side) = (tensors[a].channels, tensors[a].side / 2); - let mut data = vec![0f32; channels * side * side]; + let mut data = tensor(channels, side, &mut total)?; for ch in 0..channels { for y in 0..side { for x in 0..side { @@ -92,7 +122,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> let a = get(&tensors, *src)?; let (channels, side) = (tensors[a].channels / 4, tensors[a].side); let out_side = 2 * side; - let mut data = vec![0f32; channels * out_side * out_side]; + let mut data = tensor(channels, out_side, &mut total)?; for ch in 0..channels { for y in 0..side { for x in 0..side { @@ -131,3 +161,39 @@ impl TileRunner for ReferenceRunner { run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string())) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::ConvT2; + + fn convt(src: TensorId, cin: usize, cout: usize) -> Op { + Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None }) + } + + /// A graph that fits the final-shape check while zooming its intermediates out and back: 256 `ConvT2`s double + /// the sides (256 tiers), 255 `MaxPool`s halve them back to the layer above the input. + fn hostile() -> Net { + let mut ops = Vec::new(); + let mut last = 0usize; + for _ in 0..256 { + ops.push(convt(last, 1, 1)); + last += 1; + } + for _ in 0..255 { + ops.push(Op::MaxPool2 { src: last }); + last += 1; + } + Net::new(1, ops).unwrap() + } + + /// The zoomed-out layers' memory was never reserved: either one tensor or the retained whole (the two bounds + /// share the cap) says why the run stops. + #[test] + fn a_graph_that_zooms_its_intermediates_out_and_back_is_refused_before_their_memory_is_reserved() { + assert!(matches!( + run(&hostile(), 1, &[1.0]), + Err(NetError::Invalid(why)) if why.contains("too large") || why.contains("too much working memory") + )); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9caa8cdb0..0b203f795 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -39,7 +39,7 @@ thiserror = { workspace = true } log = { workspace = true } ab_glyph = { workspace = true } # Apply the font’s vertical substitutions and origins to Japanese watermarks. -harfrust = { version = "0.12", default-features = false, features = ["std"] } +harfrust = { version = "0.13", default-features = false, features = ["std"] } unicode-segmentation = "1.13" lightcraft-segment = { workspace = true, optional = true } diff --git a/crates/engine/src/apple_photos.rs b/crates/engine/src/apple_photos.rs index 01394cd5f..c0b562700 100644 --- a/crates/engine/src/apple_photos.rs +++ b/crates/engine/src/apple_photos.rs @@ -777,526 +777,4 @@ pub fn osascript() -> Runner { } #[cfg(test)] -mod tests { - use super::*; - - /// A runner that records the arguments it got and answers `answer`. - fn fake(answer: Result) -> (Runner, Arc>>>) { - let calls = Arc::new(Mutex::new(Vec::new())); - let c = calls.clone(); - let r: Runner = Arc::new(move |args: &[String], _| { - c.lock().unwrap().push(args.to_vec()); - answer.clone() - }); - (r, calls) - } - - fn ok(stdout: &str) -> Result { - Ok(Output { status: Some(0), stdout: stdout.into(), stderr: String::new() }) - } - - fn failed(stderr: &str) -> Output { - Output { status: Some(1), stdout: String::new(), stderr: stderr.into() } - } - - /// The script part of an argument list: everything before the marker. - fn script_of(args: &[String]) -> &[String] { - let at = args.iter().position(|a| a == MARKER).unwrap(); - &args[..at] - } - - #[test] - fn names_and_paths_are_arguments_never_script() { - let hostile = [ - "", - "Holiday \"2026\"", - "back\\slash\\", - "line\nbreak\r\nand\ttab", - "end tell\ndo shell script \"rm -rf ~\"", - "-e", - "--help", - "日本の写真 📷 Ünïcödé", - "'single' & \"double\" » «", - ]; - let base = osascript_args("", &["/tmp/a.jpg".into()]); - for album in hostile { - let paths = vec!["/tmp/with \"quotes\".jpg".to_string(), "/tmp/new\nline/é\\x.jpg".to_string(), format!("/tmp/-{album}.jpg")]; - let args = osascript_args(album, &paths); - // the script is the same whatever the input… - assert_eq!(script_of(&args), script_of(&base)); - assert!(script_of(&args).chunks(2).all(|c| c[0] == "-e" && SCRIPT.lines().any(|l| l == c[1]))); - // …and the input follows the marker as whole arguments, unchanged - let at = args.iter().position(|a| a == MARKER).unwrap(); - assert_eq!(&args[at + 1], album); - assert_eq!(&args[at + 2..], &paths[..]); - } - // the script reads them from argv only - assert!(SCRIPT.starts_with("on run argv") && SCRIPT.contains("item 2 of argv") && SCRIPT.contains("item i of argv")); - assert!(!SCRIPT.contains("do shell script")); - // it imports into the album (made when missing) without skipping Photos' duplicate check - assert!(SCRIPT.contains("make new album named albumName")); - assert!(SCRIPT.contains("import mediaFiles into targetAlbum skip check duplicates false")); - } - - /// Photos' `album` elements are every album, those in folders too, in no set order: the script - /// keeps the top-level ones of exactly that name and refuses when there are several. - #[test] - fn the_album_is_a_single_top_level_one() { - let lookup = SCRIPT.find("every album whose name is albumName").unwrap(); - let choose = SCRIPT.find("set targetAlbum to item 1 of topLevel").unwrap(); - let make = SCRIPT.find("make new album named albumName").unwrap(); - let import = SCRIPT.find("import mediaFiles into targetAlbum").unwrap(); - assert!(lookup < choose && choose < make && make < import); - let filter = &SCRIPT[lookup..choose]; - assert!(filter.contains("parent of candidate") && filter.contains("candidateParent is missing value"), "{filter}"); - assert!(filter.contains("considering case"), "exact names: {filter}"); - assert!(filter.contains(&format!( - "if (count of topLevel) > 1 then error \"LightCraft: several top-level albums have this name\" number {AMBIGUOUS_ALBUM}" - ))); - let m = explain(&failed(&format!("execution error: LightCraft: several top-level albums have this name ({AMBIGUOUS_ALBUM})")), "Trip"); - assert!(m.contains("More than one album at the top level of Photos is named “Trip”") && m.contains("nothing was added"), "{m}"); - } - - #[test] - fn ids_are_one_per_line() { - assert_eq!(parse_ids("A1B2/L0/001\nC3D4/L0/001\n"), vec!["A1B2/L0/001", "C3D4/L0/001"]); - assert_eq!(parse_ids(" X/L0/001 \r\n\r\n Y/L0/001"), vec!["X/L0/001", "Y/L0/001"]); - assert!(parse_ids("").is_empty() && parse_ids("\n \n").is_empty()); - } - - #[test] - fn errors_say_what_to_do() { - let says = |stderr: &str, part: &str| { - let m = explain(&failed(stderr), "Trip"); - assert!(m.contains(part), "{stderr:?}: {m}"); - }; - says("146:180: execution error: Not authorized to send Apple events to Photos. (-1743)", "System Settings › Privacy & Security › Automation"); - says("execution error: Photos got an error: … (-1744)", "Automation"); - says("execution error: Can’t get application \"Photos\". (-10814)", "couldn't be found"); - says("execution error: Photos got an error: Application isn’t running. (-600)", "couldn't be found or opened"); - says("execution error: User canceled. (-128)", "cancelled"); - says("execution error: Photos got an error: AppleEvent timed out. (-1712)", "didn't answer in time"); - says("execution error: File not found (-43)", "couldn't find a file"); - says("execution error: Photos got an error: Something odd happened. (-2700)", "Something odd happened"); - assert!(explain(&Output { status: Some(3), ..Default::default() }, "").contains("exited with 3")); - assert!(explain(&Output { status: None, ..Default::default() }, "").contains("stopped")); - assert!(explain_run_error(&RunError::NoPhotos).contains("couldn't be found")); - assert!(explain_run_error(&RunError::Spawn("denied".into())).contains("denied")); - assert!(explain_run_error(&RunError::Timeout(Duration::from_secs(65))).contains("65 s")); - // a stray parenthesis is not an error number - assert!(explain(&failed("execution error: (not a number)"), "").contains("not a number")); - } - - fn files(tag: &str, n: usize) -> (std::path::PathBuf, Vec) { - let dir = std::env::temp_dir().join(format!("lc-photos-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let paths = (0..n) - .map(|i| { - let p = dir.join(format!("photo {i} é.jpg")); - std::fs::write(&p, b"jpeg").unwrap(); - p.to_string_lossy().to_string() - }) - .collect(); - (dir, paths) - } - - #[test] - fn an_import_passes_the_files_and_returns_the_ids() { - let (dir, paths) = files("import", 2); - let (runner, calls) = fake(ok("ID-1/L0/001\nID-2/L0/001\n")); - let got = import(&runner, &paths, " Trip “2026” ").unwrap(); - assert_eq!(got, Imported { ids: vec!["ID-1/L0/001".into(), "ID-2/L0/001".into()], requested: 2, album: Some("Trip “2026”".into()) }); - assert_eq!(got.warning(), None); - let args = calls.lock().unwrap()[0].clone(); - let at = args.iter().position(|a| a == MARKER).unwrap(); - assert_eq!(&args[at + 1..], &[vec!["Trip “2026”".to_string()], paths.clone()].concat()[..]); - // fewer back than asked: a partial import, said so - let (runner, _) = fake(ok("ID-1/L0/001\n")); - let got = import(&runner, &paths, "").unwrap(); - assert_eq!((got.ids.len(), got.album.clone()), (1, None)); - assert!(got.warning().unwrap().contains("1 of 2")); - assert_eq!(got.to_json()["warning"], json!(got.warning().unwrap())); - // a failed run is the mapped message - let (runner, _) = fake(Ok(failed("execution error: Not authorized to send Apple events to Photos. (-1743)"))); - assert!(import(&runner, &paths, "x").unwrap_err().contains("Automation")); - // a timeout names the request's limit - let (runner, _) = fake(Err(RunError::Timeout(Duration::from_secs(70)))); - assert!(import(&runner, &paths, "x").unwrap_err().contains(&format!("{} s", timeout_for(2).as_secs()))); - let _ = std::fs::remove_dir_all(dir); - } - - /// Codex review of #643: every path was an argument of one osascript run, so a big export - /// (10,000 files) went over macOS's 1 MiB argument limit and Photos got nothing. - #[test] - fn big_imports_run_in_batches_within_the_argument_limit() { - let long = format!("/{}", "d/".repeat(500)); // about 1,000 bytes, near the longest path macOS opens - let paths: Vec = (0..10_000).map(|i| format!("{long}{i:05}.jpg")).collect(); - let runs = Arc::new(Mutex::new(Vec::<(usize, usize, Duration)>::new())); - let r = runs.clone(); - let runner: Runner = Arc::new(move |args: &[String], limit| { - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - r.lock().unwrap().push((files, args.iter().map(|a| a.len() + 1).sum(), limit)); - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - let got = execute(&runner, &paths, "Big").unwrap(); - assert_eq!((got.ids.len(), got.requested, got.warning()), (10_000, 10_000, None)); - let runs = runs.lock().unwrap().clone(); - assert_eq!(runs.len(), 10_000 / BATCH); - assert!(runs.iter().all(|&(files, _, _)| files == BATCH)); - let biggest = runs.iter().map(|&(_, bytes, _)| bytes).max().unwrap(); - assert!(biggest < 1 << 18, "{biggest} bytes of arguments in one run"); - // every run gets what is left of the request's limit, never more - assert!(runs.iter().all(|&(_, _, limit)| limit <= timeout_for(10_000))); - - // a later batch failing: the error says how far Photos got - let calls = Arc::new(Mutex::new(0)); - let c = calls.clone(); - let runner: Runner = Arc::new(move |args: &[String], _| { - let mut n = c.lock().unwrap(); - *n += 1; - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - if *n == 2 { - return Ok(failed("execution error: Photos got an error: User canceled. (-128)")); - } - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - let e = execute(&runner, &paths[..450], "Big").unwrap_err(); - assert!(e.contains("cancelled") && e.contains("200 of the 450 files"), "{e}"); - assert_eq!(*calls.lock().unwrap(), 2, "nothing more is sent after a failed batch"); - - // the batches share the request's one deadline: time a batch takes comes off the next one's - let limits = Arc::new(Mutex::new(Vec::::new())); - let l = limits.clone(); - let runner: Runner = Arc::new(move |args: &[String], limit| { - l.lock().unwrap().push(limit); - std::thread::sleep(Duration::from_millis(300)); - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - execute(&runner, &paths[..450], "Big").unwrap(); - let limits = limits.lock().unwrap().clone(); - assert_eq!(limits.len(), 3); - let whole = timeout_for(450); - assert!(limits[0] <= whole && limits[0] > whole - Duration::from_millis(250), "{limits:?}"); - assert!(limits[1] <= whole - Duration::from_millis(300) && limits[2] <= whole - Duration::from_millis(600), "{limits:?}"); - } - - #[test] - fn bad_requests_never_reach_osascript() { - let (dir, paths) = files("bad", 1); - let (runner, calls) = fake(ok("")); - assert!(import(&runner, &[], "").unwrap_err().contains("no files")); - assert!(import(&runner, &["relative/a.jpg".into()], "").unwrap_err().contains("absolute")); - assert!(import(&runner, &[format!("{}/missing.jpg", dir.display())], "").unwrap_err().contains("no such file")); - assert!(import(&runner, &[format!("{}/a\0b.jpg", dir.display())], "").unwrap_err().contains("NUL")); - assert!(import(&runner, &paths, "a\0b").unwrap_err().contains("NUL")); - assert!(calls.lock().unwrap().is_empty()); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn after_an_export_the_written_files_are_added() { - let (dir, paths) = files("after", 2); - let exported = vec![ - json!({"path": paths[0], "width": 10}), - json!({"skipped": "/elsewhere/x.jpg"}), - json!({"photo": 3, "error": "x"}), - json!({"path": paths[1]}), - ]; - let (runner, calls) = fake(ok("A/L0/001\nB/L0/001")); - let imports = Imports::default(); - let out = imports.reserve(runner.clone(), " Album ").unwrap().run(&exported, false); - assert_eq!(out["imported"], 2); - assert_eq!(out["album"], "Album"); - assert_eq!((out["job"].as_u64(), out["running"].as_bool()), (Some(1), Some(false))); - let args = calls.lock().unwrap()[0].clone(); - assert_eq!(&args[args.len() - 2..], &paths[..], "only the files written, in order"); - // nothing written, or cancelled: Photos isn't asked, and the slot is free again - let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false); - assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}"); - assert_eq!(out.get("cancelled"), None, "{out}"); - let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true); - assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}"); - assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}"); - assert_eq!(calls.lock().unwrap().len(), 1); - assert!(imports.running().is_none()); - let _ = std::fs::remove_dir_all(dir); - } - - /// An export that will add its files reserves the slot before writing them: a competing - /// import is refused, naming the export's job, and the export is never turned away after - /// writing. Every way the export can end frees the slot. - #[test] - fn an_export_reserves_the_import_before_writing() { - let (dir, paths) = files("reserve", 1); - let (runner, calls) = fake(ok("A\n")); - let imports = Imports::default(); - let reserved = imports.reserve(runner.clone(), "Trip").unwrap(); - assert_eq!(reserved.job().map(|j| j.json()), Some(json!({"job": 1, "running": true, "exporting": true, "requested": 0, "album": "Trip"}))); - // while the export writes: other imports and exports are refused, naming it - let e = import_job(&runner, &imports, paths.clone(), "", false, None).unwrap_err(); - assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}"); - assert!(imports.reserve(runner.clone(), "").err().unwrap().contains("(import 1)")); - assert!(calls.lock().unwrap().is_empty()); - // the export hands over its files: they are imported under its job - let out = reserved.run(&[json!({"path": paths[0]})], false); - assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out.get("exporting")), (Some(1), Some(1), None), "{out}"); - // an export that fails or never runs (its reservation dropped) frees the slot - let dropped = imports.reserve(runner.clone(), "").unwrap(); - let id = dropped.job().unwrap().id; - drop(dropped); - let j = imports.get(id).unwrap(); - assert!(j.finished() && !j.take_announcement(), "skipped quietly"); - assert!(j.json()["skipped"].as_str().unwrap().contains("ended before writing")); - // a file the export reported but that is gone: Photos isn't asked, the slot is freed - let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"path": format!("{}/gone.jpg", dir.display())})], false); - assert!(out["error"].as_str().unwrap().contains("no such file"), "{out}"); - assert_eq!(calls.lock().unwrap().len(), 1); - assert!(imports.running().is_none()); - assert_eq!(import_job(&runner, &imports, paths, "", false, None).unwrap().id, 4); - let _ = std::fs::remove_dir_all(dir); - } - - /// Review of #236: a runner that panics, here or on a worker, ends its import as failed and - /// frees the slot; it never stays "running", refusing every later import. - #[test] - fn a_panicking_runner_frees_the_slot() { - let (dir, paths) = files("panic", 1); - let panicky: Runner = Arc::new(|_: &[String], _| panic!("runner exploded")); - let (good, _) = fake(ok("A\n")); - let imports = Imports::default(); - let stopped = |j: &ImportJob| j.json()["error"].as_str().is_some_and(|e| e.contains("stopped unexpectedly")); - // waiting here - let job = import_job(&panicky, &imports, paths.clone(), "", false, None).unwrap(); - assert!(job.finished() && stopped(&job), "{}", job.json()); - assert!(imports.running().is_none()); - // on a worker - let job = import_job(&panicky, &imports, paths.clone(), "", true, None).unwrap(); - let t0 = std::time::Instant::now(); - while !job.finished() { - assert!(t0.elapsed() < Duration::from_secs(20)); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(stopped(&job), "{}", job.json()); - // an export's reservation, waiting and not - let out = imports.reserve(panicky.clone(), "").unwrap().run(&[json!({"path": paths[0]})], false); - assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}"); - assert!(imports.running().is_none()); - let started = imports.reserve(panicky, "").unwrap().start(&[json!({"path": paths[0]})]); - let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); - while !job.finished() { - assert!(t0.elapsed() < Duration::from_secs(20)); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(stopped(&job)); - // the slot is free: the next import runs - assert_eq!(import_job(&good, &imports, paths, "", false, None).unwrap().json()["imported"], 1); - let _ = std::fs::remove_dir_all(dir); - } - - /// A process about to end waits for an import still running, so it never abandons osascript. - #[test] - fn waiting_for_idle_outlasts_a_running_import() { - let (dir, paths) = files("idle", 1); - let (runner, go) = delayed(); - let imports = Imports::default(); - let job = import_job(&runner, &imports, paths, "", true, None).unwrap(); - assert!(!job.finished()); - let release = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - go.send(()).unwrap(); - }); - imports.wait_idle(); - assert!(job.finished() && imports.running().is_none()); - release.join().unwrap(); - // nothing running: returns at once - imports.wait_idle(); - let _ = std::fs::remove_dir_all(dir); - } - - /// Review of #236: shutdown while an export is still writing the files it reserved the import - /// for. The deadline follows the job: the write allowance while the export writes, then the - /// import's own time limit from when Photos was asked, not one deadline fixed at the start - /// (which gave up with the import still inside its limit). Scaled down: 300 ms of writing, - /// imports limited to 1.5 s (+ 0.1 s). - #[test] - fn shutdown_follows_an_export_into_its_import() { - let (dir, paths) = files("shutdown", 2); - let limits = WaitLimits { - writing: Duration::from_millis(300), - import: |_| Duration::from_millis(1500), - margin: Duration::from_millis(100), - cap: Duration::from_secs(20), - }; - let taking = |ms: u64| -> Runner { - Arc::new(move |args: &[String], _| { - std::thread::sleep(Duration::from_millis(ms)); - let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) - }) - }; - let imports = Imports::default(); - // the export is still writing when the wait begins; Photos then takes 1 s, past the write - // allowance but inside the import's own limit - let reserved = imports.reserve(taking(1000), "").unwrap(); - let written: Vec = paths.iter().map(|p| json!({"path": p})).collect(); - let exporter = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(150)); - reserved.run(&written, false) - }); - let t0 = std::time::Instant::now(); - assert!(imports.wait_idle_within(limits), "waited for the import, not just the write allowance"); - assert!(t0.elapsed() >= Duration::from_millis(1000), "{:?}", t0.elapsed()); - assert_eq!(exporter.join().unwrap()["imported"], 2); - // an import past its own limit is given up on at its deadline (not the cap) - let job = import_job(&taking(3000), &imports, paths.clone(), "", true, None).unwrap(); - let t0 = std::time::Instant::now(); - assert!(!imports.wait_idle_within(limits)); - let waited = t0.elapsed(); - assert!(waited >= Duration::from_millis(1500) && waited < Duration::from_millis(2900), "{waited:?}"); - while !job.finished() { - std::thread::sleep(Duration::from_millis(20)); - } - // an export that never hands its files over: the write allowance, then it's left - let held = imports.reserve(taking(0), "").unwrap(); - let t0 = std::time::Instant::now(); - assert!(!imports.wait_idle_within(limits)); - let waited = t0.elapsed(); - assert!(waited >= Duration::from_millis(300) && waited < Duration::from_millis(1500), "{waited:?}"); - drop(held); - assert!(imports.wait_idle_within(limits)); - // and the cap holds over everything - let job = import_job(&taking(1000), &imports, paths, "", true, None).unwrap(); - let t0 = std::time::Instant::now(); - assert!(!imports.wait_idle_within(WaitLimits { cap: Duration::from_millis(200), ..limits })); - assert!(t0.elapsed() < Duration::from_millis(900)); - while !job.finished() { - std::thread::sleep(Duration::from_millis(20)); - } - let _ = std::fs::remove_dir_all(dir); - } - - /// A runner that waits for `go` (at most 20 s, so a regression fails instead of hanging), then - /// answers one id per file. - fn delayed() -> (Runner, std::sync::mpsc::Sender<()>) { - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = Mutex::new(wait); - let r: Runner = Arc::new(move |args: &[String], _| { - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - (r, go) - } - - /// Imports run as jobs: in the background the caller gets the job at once and its outcome - /// later; while one runs a second is refused (a retry must not add the files twice). - #[test] - fn a_slow_import_runs_as_a_job_and_refuses_a_second_one() { - let (dir, paths) = files("job", 2); - let (runner, go) = delayed(); - let imports = Imports::default(); - let t0 = std::time::Instant::now(); - let job = import_job(&runner, &imports, paths.clone(), " Trip ", true, None).unwrap(); - assert!(t0.elapsed() < Duration::from_secs(5), "the caller doesn't wait for Photos"); - assert_eq!(job.json(), json!({"job": 1, "running": true, "requested": 2, "album": "Trip"})); - assert_eq!(imports.running().map(|j| j.id), Some(1)); - let e = import_job(&runner, &imports, paths.clone(), "", true, None).unwrap_err(); - assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}"); - let e = imports.reserve(runner.clone(), "").err().unwrap(); - assert!(e.contains("still adding"), "{e}"); - go.send(()).unwrap(); - while !job.finished() { - assert!(t0.elapsed() < Duration::from_secs(20), "the job finishes"); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(job.take_announcement() && !job.take_announcement(), "announced once"); - let done = imports.get(1).unwrap().json(); - assert_eq!((done["running"].as_bool(), done["imported"].as_u64(), done["album"].as_str()), (Some(false), Some(2), Some("Trip")), "{done}"); - // finished: the next one may start; a failed one says why - let (fails, _) = fake(Err(RunError::Spawn("denied".into()))); - let job = import_job(&fails, &imports, paths, "", false, None).unwrap(); - assert_eq!(job.id, 2); - assert!(job.json()["error"].as_str().unwrap().contains("denied")); - assert!(imports.running().is_none() && imports.latest().is_some_and(|j| j.id == 2)); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn a_session_remembers_its_last_imports() { - let (dir, paths) = files("kept", 1); - let (runner, _) = fake(ok("A\n")); - let imports = Imports::default(); - for _ in 0..KEPT_JOBS + 3 { - import_job(&runner, &imports, paths.clone(), "", false, None).unwrap(); - } - let all = imports.all(); - assert_eq!(all.len(), KEPT_JOBS); - assert_eq!((all[0].id, all[KEPT_JOBS - 1].id), (4, KEPT_JOBS as u64 + 3)); - assert!(imports.get(1).is_none()); - let _ = std::fs::remove_dir_all(dir); - } - - /// The activity stack (issue #345): an import of its own shows a row while Photos works (no ✕, - /// no count, the album as detail) and loses it when Photos is done; an export's own import - /// shows none of its own, the export's row covers it. - #[test] - fn imports_of_their_own_show_in_the_activity_stack() { - let (dir, paths) = files("activity", 2); - let activity = Activity::default(); - let seen = Arc::new(Mutex::new(Vec::new())); - // Photos answers when the test lets it (`go`), so no assertion races the import's end - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = Mutex::new(wait); - let (a, rows) = (activity.clone(), seen.clone()); - let runner: Runner = Arc::new(move |args: &[String], _| { - rows.lock().unwrap().push(a.list()); - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - let n = args.len() - args.iter().position(|x| x == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) - }); - let imports = Imports::default(); - let ended = |job: &ImportJob| { - let deadline = std::time::Instant::now() + Duration::from_secs(20); - while !(job.finished() && activity.list().is_empty()) { - assert!(std::time::Instant::now() < deadline, "the import ends and its row goes"); - std::thread::sleep(Duration::from_millis(5)); - } - }; - // waiting for Photos here - go.send(()).unwrap(); - let job = import_job(&runner, &imports, paths.clone(), "Trip", false, Some(&activity)).unwrap(); - assert_eq!(job.json()["imported"], 2); - assert!(activity.list().is_empty(), "the row goes when Photos is done"); - let during = seen.lock().unwrap().last().cloned().unwrap(); - assert_eq!(during.len(), 1, "{during:?}"); - assert_eq!((during[0].kind, during[0].label.as_str(), during[0].detail.as_str()), (ACTIVITY_KIND, ACTIVITY_LABEL, "Trip")); - assert_eq!((during[0].cancellable, during[0].total), (false, 0), "no ✕ and no count"); - // on a worker thread: the row stays while Photos works, and goes once the import ends - let job = import_job(&runner, &imports, paths.clone(), "", true, Some(&activity)).unwrap(); - assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); - go.send(()).unwrap(); - ended(&job); - // an export's own import: the export's row covers it - let exported: Vec = paths.iter().map(|p| json!({"path": p})).collect(); - go.send(()).unwrap(); - imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).run(&exported, false); - assert!(seen.lock().unwrap().last().unwrap().is_empty(), "no row of its own while the export's worker waits"); - // ...unless it is left running on its own once the export is done - let started = imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).start(&exported); - assert_eq!(started["running"], true); - assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); - go.send(()).unwrap(); - let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); - ended(&job); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn timeouts_grow_with_the_batch_and_stay_bounded() { - assert_eq!(timeout_for(1), Duration::from_secs(605)); - assert_eq!(timeout_for(100), Duration::from_secs(1100)); - assert_eq!(timeout_for(usize::MAX), Duration::from_secs(7200)); - } -} +mod tests; diff --git a/crates/engine/src/apple_photos/tests.rs b/crates/engine/src/apple_photos/tests.rs new file mode 100644 index 000000000..f10929856 --- /dev/null +++ b/crates/engine/src/apple_photos/tests.rs @@ -0,0 +1,521 @@ +use super::*; + +/// A runner that records the arguments it got and answers `answer`. +fn fake(answer: Result) -> (Runner, Arc>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + let c = calls.clone(); + let r: Runner = Arc::new(move |args: &[String], _| { + c.lock().unwrap().push(args.to_vec()); + answer.clone() + }); + (r, calls) +} + +fn ok(stdout: &str) -> Result { + Ok(Output { status: Some(0), stdout: stdout.into(), stderr: String::new() }) +} + +fn failed(stderr: &str) -> Output { + Output { status: Some(1), stdout: String::new(), stderr: stderr.into() } +} + +/// The script part of an argument list: everything before the marker. +fn script_of(args: &[String]) -> &[String] { + let at = args.iter().position(|a| a == MARKER).unwrap(); + &args[..at] +} + +#[test] +fn names_and_paths_are_arguments_never_script() { + let hostile = [ + "", + "Holiday \"2026\"", + "back\\slash\\", + "line\nbreak\r\nand\ttab", + "end tell\ndo shell script \"rm -rf ~\"", + "-e", + "--help", + "日本の写真 📷 Ünïcödé", + "'single' & \"double\" » «", + ]; + let base = osascript_args("", &["/tmp/a.jpg".into()]); + for album in hostile { + let paths = vec!["/tmp/with \"quotes\".jpg".to_string(), "/tmp/new\nline/é\\x.jpg".to_string(), format!("/tmp/-{album}.jpg")]; + let args = osascript_args(album, &paths); + // the script is the same whatever the input… + assert_eq!(script_of(&args), script_of(&base)); + assert!(script_of(&args).chunks(2).all(|c| c[0] == "-e" && SCRIPT.lines().any(|l| l == c[1]))); + // …and the input follows the marker as whole arguments, unchanged + let at = args.iter().position(|a| a == MARKER).unwrap(); + assert_eq!(&args[at + 1], album); + assert_eq!(&args[at + 2..], &paths[..]); + } + // the script reads them from argv only + assert!(SCRIPT.starts_with("on run argv") && SCRIPT.contains("item 2 of argv") && SCRIPT.contains("item i of argv")); + assert!(!SCRIPT.contains("do shell script")); + // it imports into the album (made when missing) without skipping Photos' duplicate check + assert!(SCRIPT.contains("make new album named albumName")); + assert!(SCRIPT.contains("import mediaFiles into targetAlbum skip check duplicates false")); +} + +/// Photos' `album` elements are every album, those in folders too, in no set order: the script +/// keeps the top-level ones of exactly that name and refuses when there are several. +#[test] +fn the_album_is_a_single_top_level_one() { + let lookup = SCRIPT.find("every album whose name is albumName").unwrap(); + let choose = SCRIPT.find("set targetAlbum to item 1 of topLevel").unwrap(); + let make = SCRIPT.find("make new album named albumName").unwrap(); + let import = SCRIPT.find("import mediaFiles into targetAlbum").unwrap(); + assert!(lookup < choose && choose < make && make < import); + let filter = &SCRIPT[lookup..choose]; + assert!(filter.contains("parent of candidate") && filter.contains("candidateParent is missing value"), "{filter}"); + assert!(filter.contains("considering case"), "exact names: {filter}"); + assert!(filter.contains(&format!( + "if (count of topLevel) > 1 then error \"LightCraft: several top-level albums have this name\" number {AMBIGUOUS_ALBUM}" + ))); + let m = explain(&failed(&format!("execution error: LightCraft: several top-level albums have this name ({AMBIGUOUS_ALBUM})")), "Trip"); + assert!(m.contains("More than one album at the top level of Photos is named “Trip”") && m.contains("nothing was added"), "{m}"); +} + +#[test] +fn ids_are_one_per_line() { + assert_eq!(parse_ids("A1B2/L0/001\nC3D4/L0/001\n"), vec!["A1B2/L0/001", "C3D4/L0/001"]); + assert_eq!(parse_ids(" X/L0/001 \r\n\r\n Y/L0/001"), vec!["X/L0/001", "Y/L0/001"]); + assert!(parse_ids("").is_empty() && parse_ids("\n \n").is_empty()); +} + +#[test] +fn errors_say_what_to_do() { + let says = |stderr: &str, part: &str| { + let m = explain(&failed(stderr), "Trip"); + assert!(m.contains(part), "{stderr:?}: {m}"); + }; + says("146:180: execution error: Not authorized to send Apple events to Photos. (-1743)", "System Settings › Privacy & Security › Automation"); + says("execution error: Photos got an error: … (-1744)", "Automation"); + says("execution error: Can’t get application \"Photos\". (-10814)", "couldn't be found"); + says("execution error: Photos got an error: Application isn’t running. (-600)", "couldn't be found or opened"); + says("execution error: User canceled. (-128)", "cancelled"); + says("execution error: Photos got an error: AppleEvent timed out. (-1712)", "didn't answer in time"); + says("execution error: File not found (-43)", "couldn't find a file"); + says("execution error: Photos got an error: Something odd happened. (-2700)", "Something odd happened"); + assert!(explain(&Output { status: Some(3), ..Default::default() }, "").contains("exited with 3")); + assert!(explain(&Output { status: None, ..Default::default() }, "").contains("stopped")); + assert!(explain_run_error(&RunError::NoPhotos).contains("couldn't be found")); + assert!(explain_run_error(&RunError::Spawn("denied".into())).contains("denied")); + assert!(explain_run_error(&RunError::Timeout(Duration::from_secs(65))).contains("65 s")); + // a stray parenthesis is not an error number + assert!(explain(&failed("execution error: (not a number)"), "").contains("not a number")); +} + +fn files(tag: &str, n: usize) -> (std::path::PathBuf, Vec) { + let dir = std::env::temp_dir().join(format!("lc-photos-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = (0..n) + .map(|i| { + let p = dir.join(format!("photo {i} é.jpg")); + std::fs::write(&p, b"jpeg").unwrap(); + p.to_string_lossy().to_string() + }) + .collect(); + (dir, paths) +} + +#[test] +fn an_import_passes_the_files_and_returns_the_ids() { + let (dir, paths) = files("import", 2); + let (runner, calls) = fake(ok("ID-1/L0/001\nID-2/L0/001\n")); + let got = import(&runner, &paths, " Trip “2026” ").unwrap(); + assert_eq!(got, Imported { ids: vec!["ID-1/L0/001".into(), "ID-2/L0/001".into()], requested: 2, album: Some("Trip “2026”".into()) }); + assert_eq!(got.warning(), None); + let args = calls.lock().unwrap()[0].clone(); + let at = args.iter().position(|a| a == MARKER).unwrap(); + assert_eq!(&args[at + 1..], &[vec!["Trip “2026”".to_string()], paths.clone()].concat()[..]); + // fewer back than asked: a partial import, said so + let (runner, _) = fake(ok("ID-1/L0/001\n")); + let got = import(&runner, &paths, "").unwrap(); + assert_eq!((got.ids.len(), got.album.clone()), (1, None)); + assert!(got.warning().unwrap().contains("1 of 2")); + assert_eq!(got.to_json()["warning"], json!(got.warning().unwrap())); + // a failed run is the mapped message + let (runner, _) = fake(Ok(failed("execution error: Not authorized to send Apple events to Photos. (-1743)"))); + assert!(import(&runner, &paths, "x").unwrap_err().contains("Automation")); + // a timeout names the request's limit + let (runner, _) = fake(Err(RunError::Timeout(Duration::from_secs(70)))); + assert!(import(&runner, &paths, "x").unwrap_err().contains(&format!("{} s", timeout_for(2).as_secs()))); + let _ = std::fs::remove_dir_all(dir); +} + +/// Codex review of #643: every path was an argument of one osascript run, so a big export +/// (10,000 files) went over macOS's 1 MiB argument limit and Photos got nothing. +#[test] +fn big_imports_run_in_batches_within_the_argument_limit() { + let long = format!("/{}", "d/".repeat(500)); // about 1,000 bytes, near the longest path macOS opens + let paths: Vec = (0..10_000).map(|i| format!("{long}{i:05}.jpg")).collect(); + let runs = Arc::new(Mutex::new(Vec::<(usize, usize, Duration)>::new())); + let r = runs.clone(); + let runner: Runner = Arc::new(move |args: &[String], limit| { + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + r.lock().unwrap().push((files, args.iter().map(|a| a.len() + 1).sum(), limit)); + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + let got = execute(&runner, &paths, "Big").unwrap(); + assert_eq!((got.ids.len(), got.requested, got.warning()), (10_000, 10_000, None)); + let runs = runs.lock().unwrap().clone(); + assert_eq!(runs.len(), 10_000 / BATCH); + assert!(runs.iter().all(|&(files, _, _)| files == BATCH)); + let biggest = runs.iter().map(|&(_, bytes, _)| bytes).max().unwrap(); + assert!(biggest < 1 << 18, "{biggest} bytes of arguments in one run"); + // every run gets what is left of the request's limit, never more + assert!(runs.iter().all(|&(_, _, limit)| limit <= timeout_for(10_000))); + + // a later batch failing: the error says how far Photos got + let calls = Arc::new(Mutex::new(0)); + let c = calls.clone(); + let runner: Runner = Arc::new(move |args: &[String], _| { + let mut n = c.lock().unwrap(); + *n += 1; + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + if *n == 2 { + return Ok(failed("execution error: Photos got an error: User canceled. (-128)")); + } + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + let e = execute(&runner, &paths[..450], "Big").unwrap_err(); + assert!(e.contains("cancelled") && e.contains("200 of the 450 files"), "{e}"); + assert_eq!(*calls.lock().unwrap(), 2, "nothing more is sent after a failed batch"); + + // the batches share the request's one deadline: time a batch takes comes off the next one's + let limits = Arc::new(Mutex::new(Vec::::new())); + let l = limits.clone(); + let runner: Runner = Arc::new(move |args: &[String], limit| { + l.lock().unwrap().push(limit); + std::thread::sleep(Duration::from_millis(300)); + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + execute(&runner, &paths[..450], "Big").unwrap(); + let limits = limits.lock().unwrap().clone(); + assert_eq!(limits.len(), 3); + let whole = timeout_for(450); + assert!(limits[0] <= whole && limits[0] > whole - Duration::from_millis(250), "{limits:?}"); + assert!(limits[1] <= whole - Duration::from_millis(300) && limits[2] <= whole - Duration::from_millis(600), "{limits:?}"); +} + +#[test] +fn bad_requests_never_reach_osascript() { + let (dir, paths) = files("bad", 1); + let (runner, calls) = fake(ok("")); + assert!(import(&runner, &[], "").unwrap_err().contains("no files")); + assert!(import(&runner, &["relative/a.jpg".into()], "").unwrap_err().contains("absolute")); + assert!(import(&runner, &[format!("{}/missing.jpg", dir.display())], "").unwrap_err().contains("no such file")); + assert!(import(&runner, &[format!("{}/a\0b.jpg", dir.display())], "").unwrap_err().contains("NUL")); + assert!(import(&runner, &paths, "a\0b").unwrap_err().contains("NUL")); + assert!(calls.lock().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn after_an_export_the_written_files_are_added() { + let (dir, paths) = files("after", 2); + let exported = vec![ + json!({"path": paths[0], "width": 10}), + json!({"skipped": "/elsewhere/x.jpg"}), + json!({"photo": 3, "error": "x"}), + json!({"path": paths[1]}), + ]; + let (runner, calls) = fake(ok("A/L0/001\nB/L0/001")); + let imports = Imports::default(); + let out = imports.reserve(runner.clone(), " Album ").unwrap().run(&exported, false); + assert_eq!(out["imported"], 2); + assert_eq!(out["album"], "Album"); + assert_eq!((out["job"].as_u64(), out["running"].as_bool()), (Some(1), Some(false))); + let args = calls.lock().unwrap()[0].clone(); + assert_eq!(&args[args.len() - 2..], &paths[..], "only the files written, in order"); + // nothing written, or cancelled: Photos isn't asked, and the slot is free again + let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false); + assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}"); + assert_eq!(out.get("cancelled"), None, "{out}"); + let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true); + assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}"); + assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}"); + assert_eq!(calls.lock().unwrap().len(), 1); + assert!(imports.running().is_none()); + let _ = std::fs::remove_dir_all(dir); +} + +/// An export that will add its files reserves the slot before writing them: a competing +/// import is refused, naming the export's job, and the export is never turned away after +/// writing. Every way the export can end frees the slot. +#[test] +fn an_export_reserves_the_import_before_writing() { + let (dir, paths) = files("reserve", 1); + let (runner, calls) = fake(ok("A\n")); + let imports = Imports::default(); + let reserved = imports.reserve(runner.clone(), "Trip").unwrap(); + assert_eq!(reserved.job().map(|j| j.json()), Some(json!({"job": 1, "running": true, "exporting": true, "requested": 0, "album": "Trip"}))); + // while the export writes: other imports and exports are refused, naming it + let e = import_job(&runner, &imports, paths.clone(), "", false, None).unwrap_err(); + assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}"); + assert!(imports.reserve(runner.clone(), "").err().unwrap().contains("(import 1)")); + assert!(calls.lock().unwrap().is_empty()); + // the export hands over its files: they are imported under its job + let out = reserved.run(&[json!({"path": paths[0]})], false); + assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out.get("exporting")), (Some(1), Some(1), None), "{out}"); + // an export that fails or never runs (its reservation dropped) frees the slot + let dropped = imports.reserve(runner.clone(), "").unwrap(); + let id = dropped.job().unwrap().id; + drop(dropped); + let j = imports.get(id).unwrap(); + assert!(j.finished() && !j.take_announcement(), "skipped quietly"); + assert!(j.json()["skipped"].as_str().unwrap().contains("ended before writing")); + // a file the export reported but that is gone: Photos isn't asked, the slot is freed + let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"path": format!("{}/gone.jpg", dir.display())})], false); + assert!(out["error"].as_str().unwrap().contains("no such file"), "{out}"); + assert_eq!(calls.lock().unwrap().len(), 1); + assert!(imports.running().is_none()); + assert_eq!(import_job(&runner, &imports, paths, "", false, None).unwrap().id, 4); + let _ = std::fs::remove_dir_all(dir); +} + +/// Review of #236: a runner that panics, here or on a worker, ends its import as failed and +/// frees the slot; it never stays "running", refusing every later import. +#[test] +fn a_panicking_runner_frees_the_slot() { + let (dir, paths) = files("panic", 1); + let panicky: Runner = Arc::new(|_: &[String], _| panic!("runner exploded")); + let (good, _) = fake(ok("A\n")); + let imports = Imports::default(); + let stopped = |j: &ImportJob| j.json()["error"].as_str().is_some_and(|e| e.contains("stopped unexpectedly")); + // waiting here + let job = import_job(&panicky, &imports, paths.clone(), "", false, None).unwrap(); + assert!(job.finished() && stopped(&job), "{}", job.json()); + assert!(imports.running().is_none()); + // on a worker + let job = import_job(&panicky, &imports, paths.clone(), "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20)); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(stopped(&job), "{}", job.json()); + // an export's reservation, waiting and not + let out = imports.reserve(panicky.clone(), "").unwrap().run(&[json!({"path": paths[0]})], false); + assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}"); + assert!(imports.running().is_none()); + let started = imports.reserve(panicky, "").unwrap().start(&[json!({"path": paths[0]})]); + let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20)); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(stopped(&job)); + // the slot is free: the next import runs + assert_eq!(import_job(&good, &imports, paths, "", false, None).unwrap().json()["imported"], 1); + let _ = std::fs::remove_dir_all(dir); +} + +/// A process about to end waits for an import still running, so it never abandons osascript. +#[test] +fn waiting_for_idle_outlasts_a_running_import() { + let (dir, paths) = files("idle", 1); + let (runner, go) = delayed(); + let imports = Imports::default(); + let job = import_job(&runner, &imports, paths, "", true, None).unwrap(); + assert!(!job.finished()); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + go.send(()).unwrap(); + }); + imports.wait_idle(); + assert!(job.finished() && imports.running().is_none()); + release.join().unwrap(); + // nothing running: returns at once + imports.wait_idle(); + let _ = std::fs::remove_dir_all(dir); +} + +/// Review of #236: shutdown while an export is still writing the files it reserved the import +/// for. The deadline follows the job: the write allowance while the export writes, then the +/// import's own time limit from when Photos was asked, not one deadline fixed at the start +/// (which gave up with the import still inside its limit). Scaled down: 300 ms of writing, +/// imports limited to 1.5 s (+ 0.1 s). +#[test] +fn shutdown_follows_an_export_into_its_import() { + let (dir, paths) = files("shutdown", 2); + let limits = WaitLimits { + writing: Duration::from_millis(300), + import: |_| Duration::from_millis(1500), + margin: Duration::from_millis(100), + cap: Duration::from_secs(20), + }; + let taking = |ms: u64| -> Runner { + Arc::new(move |args: &[String], _| { + std::thread::sleep(Duration::from_millis(ms)); + let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + }) + }; + let imports = Imports::default(); + // the export is still writing when the wait begins; Photos then takes 1 s, past the write + // allowance but inside the import's own limit + let reserved = imports.reserve(taking(1000), "").unwrap(); + let written: Vec = paths.iter().map(|p| json!({"path": p})).collect(); + let exporter = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(150)); + reserved.run(&written, false) + }); + let t0 = std::time::Instant::now(); + assert!(imports.wait_idle_within(limits), "waited for the import, not just the write allowance"); + assert!(t0.elapsed() >= Duration::from_millis(1000), "{:?}", t0.elapsed()); + assert_eq!(exporter.join().unwrap()["imported"], 2); + // an import past its own limit is given up on at its deadline (not the cap) + let job = import_job(&taking(3000), &imports, paths.clone(), "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(limits)); + let waited = t0.elapsed(); + assert!(waited >= Duration::from_millis(1500) && waited < Duration::from_millis(2900), "{waited:?}"); + while !job.finished() { + std::thread::sleep(Duration::from_millis(20)); + } + // an export that never hands its files over: the write allowance, then it's left + let held = imports.reserve(taking(0), "").unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(limits)); + let waited = t0.elapsed(); + assert!(waited >= Duration::from_millis(300) && waited < Duration::from_millis(1500), "{waited:?}"); + drop(held); + assert!(imports.wait_idle_within(limits)); + // and the cap holds over everything + let job = import_job(&taking(1000), &imports, paths, "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(WaitLimits { cap: Duration::from_millis(200), ..limits })); + assert!(t0.elapsed() < Duration::from_millis(900)); + while !job.finished() { + std::thread::sleep(Duration::from_millis(20)); + } + let _ = std::fs::remove_dir_all(dir); +} + +/// A runner that waits for `go` (at most 20 s, so a regression fails instead of hanging), then +/// answers one id per file. +fn delayed() -> (Runner, std::sync::mpsc::Sender<()>) { + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = Mutex::new(wait); + let r: Runner = Arc::new(move |args: &[String], _| { + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + (r, go) +} + +/// Imports run as jobs: in the background the caller gets the job at once and its outcome +/// later; while one runs a second is refused (a retry must not add the files twice). +#[test] +fn a_slow_import_runs_as_a_job_and_refuses_a_second_one() { + let (dir, paths) = files("job", 2); + let (runner, go) = delayed(); + let imports = Imports::default(); + let t0 = std::time::Instant::now(); + let job = import_job(&runner, &imports, paths.clone(), " Trip ", true, None).unwrap(); + assert!(t0.elapsed() < Duration::from_secs(5), "the caller doesn't wait for Photos"); + assert_eq!(job.json(), json!({"job": 1, "running": true, "requested": 2, "album": "Trip"})); + assert_eq!(imports.running().map(|j| j.id), Some(1)); + let e = import_job(&runner, &imports, paths.clone(), "", true, None).unwrap_err(); + assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}"); + let e = imports.reserve(runner.clone(), "").err().unwrap(); + assert!(e.contains("still adding"), "{e}"); + go.send(()).unwrap(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20), "the job finishes"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(job.take_announcement() && !job.take_announcement(), "announced once"); + let done = imports.get(1).unwrap().json(); + assert_eq!((done["running"].as_bool(), done["imported"].as_u64(), done["album"].as_str()), (Some(false), Some(2), Some("Trip")), "{done}"); + // finished: the next one may start; a failed one says why + let (fails, _) = fake(Err(RunError::Spawn("denied".into()))); + let job = import_job(&fails, &imports, paths, "", false, None).unwrap(); + assert_eq!(job.id, 2); + assert!(job.json()["error"].as_str().unwrap().contains("denied")); + assert!(imports.running().is_none() && imports.latest().is_some_and(|j| j.id == 2)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_session_remembers_its_last_imports() { + let (dir, paths) = files("kept", 1); + let (runner, _) = fake(ok("A\n")); + let imports = Imports::default(); + for _ in 0..KEPT_JOBS + 3 { + import_job(&runner, &imports, paths.clone(), "", false, None).unwrap(); + } + let all = imports.all(); + assert_eq!(all.len(), KEPT_JOBS); + assert_eq!((all[0].id, all[KEPT_JOBS - 1].id), (4, KEPT_JOBS as u64 + 3)); + assert!(imports.get(1).is_none()); + let _ = std::fs::remove_dir_all(dir); +} + +/// The activity stack (issue #345): an import of its own shows a row while Photos works (no ✕, +/// no count, the album as detail) and loses it when Photos is done; an export's own import +/// shows none of its own, the export's row covers it. +#[test] +fn imports_of_their_own_show_in_the_activity_stack() { + let (dir, paths) = files("activity", 2); + let activity = Activity::default(); + let seen = Arc::new(Mutex::new(Vec::new())); + // Photos answers when the test lets it (`go`), so no assertion races the import's end + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = Mutex::new(wait); + let (a, rows) = (activity.clone(), seen.clone()); + let runner: Runner = Arc::new(move |args: &[String], _| { + rows.lock().unwrap().push(a.list()); + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let n = args.len() - args.iter().position(|x| x == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + }); + let imports = Imports::default(); + let ended = |job: &ImportJob| { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !(job.finished() && activity.list().is_empty()) { + assert!(std::time::Instant::now() < deadline, "the import ends and its row goes"); + std::thread::sleep(Duration::from_millis(5)); + } + }; + // waiting for Photos here + go.send(()).unwrap(); + let job = import_job(&runner, &imports, paths.clone(), "Trip", false, Some(&activity)).unwrap(); + assert_eq!(job.json()["imported"], 2); + assert!(activity.list().is_empty(), "the row goes when Photos is done"); + let during = seen.lock().unwrap().last().cloned().unwrap(); + assert_eq!(during.len(), 1, "{during:?}"); + assert_eq!((during[0].kind, during[0].label.as_str(), during[0].detail.as_str()), (ACTIVITY_KIND, ACTIVITY_LABEL, "Trip")); + assert_eq!((during[0].cancellable, during[0].total), (false, 0), "no ✕ and no count"); + // on a worker thread: the row stays while Photos works, and goes once the import ends + let job = import_job(&runner, &imports, paths.clone(), "", true, Some(&activity)).unwrap(); + assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); + go.send(()).unwrap(); + ended(&job); + // an export's own import: the export's row covers it + let exported: Vec = paths.iter().map(|p| json!({"path": p})).collect(); + go.send(()).unwrap(); + imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).run(&exported, false); + assert!(seen.lock().unwrap().last().unwrap().is_empty(), "no row of its own while the export's worker waits"); + // ...unless it is left running on its own once the export is done + let started = imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).start(&exported); + assert_eq!(started["running"], true); + assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); + go.send(()).unwrap(); + let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); + ended(&job); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn timeouts_grow_with_the_batch_and_stay_bounded() { + assert_eq!(timeout_for(1), Duration::from_secs(605)); + assert_eq!(timeout_for(100), Duration::from_secs(1100)); + assert_eq!(timeout_for(usize::MAX), Duration::from_secs(7200)); +} diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 1d1d7468e..1fc172548 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -16,6 +16,46 @@ use lightcraft_raw::{ profile::{HsvTable, ProfileLook, ToneCurve}, }; +mod colour; +mod hue_sat; +mod look_fit; +mod mono; +mod pairs; +mod proxy; +mod tone; + +#[cfg(test)] +pub(crate) use colour::{PROFILE_PROXY, dng_colour, dng_tone_eligible}; +#[cfg(test)] +use colour::{PROXY, starting_colour_with}; +pub(crate) use colour::{file_local_look, starting_colour, starting_colour_tags, with_starting_colour}; +pub(crate) use hue_sat::HueSat; +#[cfg(test)] +use hue_sat::{MIN_LUMINANCE_KEPT, NO_LUMINANCE_KEPT, fit_hue_sat, hue_saturation, restore_weight}; +#[cfg(test)] +pub(crate) use look_fit::fit_preview; +#[cfg(test)] +use look_fit::{ + FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, at_most, clips_more, correct_vignetting, fit_look, fit_look_with, sony_dro_off_tone, with_dro_off_tone, + without_clipped, +}; +#[cfg(test)] +use mono::{fit_dark_tone, fit_tone_only, monochrome, monochrome_look, to_prophoto}; +#[cfg(test)] +use pairs::{ + Attempt, Pairs, TONE_FITS, collect_pairs, colour_pair, displayed, fit_attempt, fit_pairs, fit_pairs_ordered, fit_pairs_with, fit_partial, + fit_tone_with_shadows, luma, rank_correlation_ties, search_ordered, +}; +#[cfg(test)] +pub(crate) use proxy::LensGeometry; +#[cfg(test)] +use proxy::{ + Cr3Framing, MIN_PREVIEW_FRACTION, alignment, estimate_cr3_framing, into_lens_geometry, proxies, reference_at, sensor_proxy, structure_agrees, +}; +pub(crate) use proxy::{fit_profile, lens_geometry, profile_pairs}; +#[cfg(test)] +use tone::{HighlightCheck, ToneFit, fit_tone, fit_tone_conditional, highlights}; + /// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone /// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because /// they keep the curve and the colour the fit produced; one written by an older version is rebuilt @@ -49,4502 +89,5 @@ pub(crate) struct CameraLook { pub hue_sat: Option, } -/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates -/// below were set at this size). -const PROXY: usize = 96; -/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples. -pub(crate) const PROFILE_PROXY: usize = 192; - -/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix: -/// their starting look is fitted to the file's own JPEG, and white balance is relative to the -/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the -/// same formats by file extension. -pub(crate) fn file_local_look(format: RawFormat) -> bool { - matches!( - format, - RawFormat::Arw - | RawFormat::Nef - | RawFormat::Nrw - | RawFormat::Rw2 - | RawFormat::Raf - | RawFormat::Cr3 - | RawFormat::Cr2 - | RawFormat::Pef - | RawFormat::Srw - | RawFormat::Orf - | RawFormat::Mrw - | RawFormat::Erf - ) -} - -/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); a look fitted to the -/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to -/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); -/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is -/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and -/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's -/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the -/// as-shot white, the camera transform at it and the fitted look. -/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from -/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. -/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit. -pub(crate) fn starting_colour( - raw: &mut RawImage, - bytes: &[u8], - lens: Option<&LensGeometry>, - embedded: Option<&lightcraft_develop::EmbeddedLens>, -) -> (Xy, CameraTransform, Option) { - let (xy, t, look) = starting_colour_with( - raw, - |raw, t| fit_preview(raw, bytes, t, lens), - |raw, t| monochrome_look(raw, bytes, t, lens), - |raw, t| { - if !file_local_look(raw.format) { - return None; - } - let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?; - fit_dark_tone(&sensor, &reference, &clipped) - }, - ); - if look.is_none() && dng_tone_eligible(raw, &t) { - let look = fit_dng_tone(raw, bytes, &t, embedded); - return (xy, t, look); - } - (xy, t, look) -} - -/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). -fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { - curve.is_none_or(|c| { - (0..=32).all(|i| { - let x = i as f32 / 32.0; - (c.eval(x) - x).abs() < 0.005 - }) - }) -} - -/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no -/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an -/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on -/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables -/// to its proxy, so it fits only the tone around them. -pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { - let p = &raw.color.profile; - raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none() -} - -/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white -/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them). -fn starting_colour_with( - raw: &mut RawImage, - fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool), - monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option, - dark: impl FnOnce(&RawImage, &CameraTransform) -> Option, -) -> (Xy, CameraTransform, Option) { - let start = |raw: &RawImage| { - let xy = lightcraft_raw::color::as_shot_white_xy(raw); - (xy, lightcraft_raw::color::camera_transform(raw, xy)) - }; - let (xy, t) = start(raw); - if !t.matrix_is_fallback { - return (xy, t, None); - } - let (look, black_and_white) = fit(raw, &t); - if let Some(look) = look { - return (xy, t, Some(look)); - } - let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) { - Some(camera) => { - camera.fill(&mut raw.color); - if lightcraft_pipeline::profiling() { - eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); - } - start(raw) - } - None => (xy, t), - }; - // A black-and-white JPEG first tries its existing tone fit on the chosen colour. - let look = if black_and_white { monochrome(raw, &t) } else { None }; - (xy, t, look.or_else(|| dark(raw, &t))) -} - -/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo -/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered -/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them). -pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option { - if lightcraft_raw::color::has_matrix(&raw.color) { - return None; - } - // the white the source shows, decided before `starting_colour` may fill in measured matrices - let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); - let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw)); - // (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling) - let (_, t, look) = starting_colour(raw, bytes, None, None); - dng_colour(&raw.color, &white, &t, look.as_ref()) -} - -/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens -/// with, when the file has no colour matrices (issue #1745: a Z 9 NEF's DNG had no `ColorMatrix1`, and other raw -/// converters refused it). The lens corrections stay in the DNG; only the fit sees the raw without them, as when -/// the photo opens. -pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) { - if lightcraft_raw::color::has_matrix(&raw.color) { - return; - } - let list3 = raw.opcodes.list3.clone(); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let tags = starting_colour_tags(raw, bytes); - raw.opcodes.list3 = list3; - if let Some(tags) = tags { - raw.color = tags; - } -} - -/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward -/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation -/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table -/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two -/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo -/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). -/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]), -/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices -/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges -/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green. -/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them. -/// `None` when the white balance or the matrix can't give tags. -pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option { - let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); - // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes - // into the baseline exposure - let white = m.apply([1.0; 3]); - let k = (white[0] + white[1] + white[2]) / 3.0; - if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) { - return None; - } - let m = Mat3(m.0.map(|r| r.map(|v| v / k))); - let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); - let wb = t.wb.map(f64::from); - let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); - let (illuminant, color_matrix) = match declared { - crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix), - crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]), - crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]), - }; - if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - return None; - } - Some(ColorData { - illuminant, - color_matrix, - // a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white - forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)], - camera_calibration: [None, None], - analog_balance: None, - as_shot_neutral: Some(neutral), - as_shot_white_xy: None, - baseline_exposure: t.baseline_exposure + k.log2(), - baseline_sharpness: tags.baseline_sharpness, - kelvin_presets: None, - profile: ProfileLook { - hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], - tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), - ..Default::default() - }, - }) -} - -/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per -/// channel. `None` when the model gives no positive neutral there. -fn declaring(xy: Xy, neutral: [f64; 3]) -> Option { - let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy); - let n = g.apply(xy.to_xyz()); - if !n.iter().all(|v| v.is_finite() && *v > 1e-9) { - return None; - } - Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g)) -} - -/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the -/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). -fn dng_tone_curve(tone: &CameraTone) -> Option { - const N: usize = 256; - let samples = (0..=N).map(|i| { - let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32); - [x, tone.apply(x).min(1.0)] - }); - let flat: Vec = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect(); - ToneCurve::from_tag(&flat) -} - -/// 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) { - if !transform.matrix_is_fallback || !file_local_look(raw.format) { - return (None, false); - } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) }; - let black_and_white = monochrome(&reference); - // A camera profile pooled from many photos knows colours this photo shows too little of; - // try its colour first and fit tone/chroma per photo (picture styles vary). - let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); - let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, - // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. - let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); - let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) { - Some(look) => look, - // A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve - // (`monochrome_look` does it without a profile). - None if colour.is_some() && black_and_white => { - let unclipped = without_clipped(&sensor, &clipped); - let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; - let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) }; - look - } - None => return (None, black_and_white), - }; - let look = if dro { with_dro_off_tone(look) } else { look }; - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}, DRO tone replaced {dro}", - raw.format, - look.matrix.0, - look.tone, - look.hue_sat.is_some(), - profile.is_some() - ); - } - (Some(look), black_and_white) -} - -/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in -/// the render's highlight reconstruction. -const SENSOR_CLIP: f32 = 0.99; - -/// [`fit_look_with`] for a photo without Sony's DRO. #[cfg(test)] -fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { - fit_look_with(sensor, reference, clipped, colour, false) -} - -/// The photo's look, refitted without the proxy pixels whose sensor values are clipped. -/// -/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip -/// level while the others keep rising, a white overcast sky comes out magenta or blue through the -/// camera matrix, while the camera JPEG shows it white. As correspondences such pixels teach the -/// colour matrix to pull colours toward grey, and the chroma curve to remove colour at their -/// display luminance (on a Z 6II forest under a clipped sky the curve cut colourfulness above -/// display luminance 0.4 to 13–19%, and every one of its samples there was clipped). The render -/// rebuilds clipped highlights separately (`lightcraft_raw::highlight`), so the look is fitted on -/// what the sensor measured. -/// -/// The search on all pixels decides, as before, whether the photo gets a look and which attempt -/// of [`search_ordered`] gives it; only that attempt is refitted without the clipped pixels, and -/// when the refit misses the acceptance gates the look on all pixels is kept. So no photo loses -/// its look and none switches attempt (a refit that just clears the gate on all pixels must not -/// replace a look fitted away from edges). A photo that had no look gets one when the search -/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly -/// closer to the camera JPEG than the neutral fallback. -fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>, dro: bool) -> Option { - let unclipped = without_clipped(sensor, clipped); - // from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them - let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false }; - let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else { - return unclipped - .as_ref() - .and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright)) - .map(|(look, _)| look) - .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright)); - }; - let Some(unclipped) = unclipped else { return Some(look) }; - let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright); - if refit.is_none() && lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels"); - } - Some(refit.unwrap_or(look)) -} - -/// `sensor` with its clipped pixels set to NaN (left out of every training pair), or `None` when -/// none is clipped. -fn without_clipped(sensor: &Rgb32f, clipped: &[bool]) -> Option { - if clipped.len() != sensor.data.len() || !clipped.iter().any(|c| *c) { - return None; - } - let mut out = sensor.clone(); - for (p, c) in out.data.iter_mut().zip(clipped) { - if *c { - *p = [f32::NAN; 3]; - } - } - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look: {} of {} proxy pixels have a clipped sensor channel", - clipped.iter().filter(|c| **c).count(), - clipped.len() - ); - } - Some(out) -} - -/// 1 where any channel of a camera-RGB pixel is at or above [`SENSOR_CLIP`], else 0. -fn clip_mask(camera: &Rgb32f) -> Rgb32f { - let mut mask = camera.clone(); - mask.map_in_place(|p| if p.iter().any(|v| *v >= SENSOR_CLIP) { [1.0; 3] } else { [0.0; 3] }); - mask -} - -/// Sony's global tone curve with the Dynamic Range Optimizer off (Standard creative style), pooled -/// from 12 CC0 raw.pixls.us ARWs of 10 interchangeable-lens bodies and their camera JPEGs; the file -/// lists them and how the curve was derived (`assets/ATTRIBUTION.md`). Pooled the same way from 55 -/// private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance -/// 0.01 and 0.5. Issue #244, `docs/camera-preview-colour.md`. -const SONY_DRO_OFF_TONE: &str = include_str!("../../../assets/camera-tone/sony-dro-off.json"); - -/// [`SONY_DRO_OFF_TONE`]'s curve (`None` only if the built-in file were invalid; a test checks it). -fn sony_dro_off_tone() -> Option { - #[derive(serde::Deserialize)] - struct File { - knots: [[f32; 2]; 32], - } - static TONE: std::sync::OnceLock> = std::sync::OnceLock::new(); - *TONE.get_or_init(|| serde_json::from_str::(SONY_DRO_OFF_TONE).ok().and_then(|f| CameraTone::new(f.knots))) -} - -/// `look`, fitted to a camera JPEG brightened by Sony's Dynamic Range Optimizer, with its tone -/// curve lowered to Sony's without DRO ([`SONY_DRO_OFF_TONE`]) wherever that is darker. DRO only -/// brightens, so the fitted curve bounds the curve without it from above, everywhere: where Sony's -/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps -/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted. -fn with_dro_off_tone(look: CameraLook) -> CameraLook { - CameraLook { tone: dro_off(&look.tone), ..look } -} - -/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker, -/// or `own` unchanged when it can't be. -fn dro_off(own_tone: &CameraTone) -> CameraTone { - let Some(dro_off) = sony_dro_off_tone() else { return *own_tone }; - let own = own_tone.knots(); - let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]); - // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots - // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the - // extended highlights would end up above the fitted curve. Raise the second-last knot (never - // above the fitted curve) until the rate is at most the fitted curve's. - let ([a, b], last) = ([own[30], own[31]], knots[31]); - let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01); - let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]); - knots[30][1] = knots[30][1].max(floor).min(last[1]); - let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone }; - if !at_most(&tone, own_tone) { - return *own_tone; - } - tone -} - -/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone -/// table covers ([`ToneMap::camera`]). -fn at_most(a: &CameraTone, b: &CameraTone) -> bool { - use lightcraft_pipeline::tone::{GREY, LUT_MAX_EV, LUT_MIN_EV, LUT_N}; - (0..LUT_N).all(|i| { - let x = GREY * 2f32.powf(LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32); - a.apply(x) <= b.apply(x) + 1e-5 - }) -} - -/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and -/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); -/// 128 MP also admits 100 MP medium-format previews. -const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; - -/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model -/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given -/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a -/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not -/// clip more highlights than the plain render ([`clips_more`]). -fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { - // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. - let refuse = |why: &str| { - if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { - eprintln!("DNGTONE refused {why}"); - } - None - }; - let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") }; - // As the loader renders it: the profile's tables around the baseline exposure. - let xy = lightcraft_raw::color::as_shot_white_xy(raw); - if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { - let gain = 2f32.powf(transform.baseline_exposure as f32); - sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); - } - correct_vignetting(&mut sensor, lens, raw.orientation); - if (sensor.width, sensor.height) != (reference.width, reference.height) { - return refuse("preview"); - } - if !structure_agrees(&sensor, &reference) { - return refuse("structure"); - } - let unclipped = without_clipped(&sensor, &clipped); - let fit_on = unclipped.as_ref().unwrap_or(&sensor); - let colour = Some((Mat3::IDENTITY, None)); - let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| { - fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright) - }) - }); - let Some(look) = look else { return refuse("fit") }; - let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { - Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), - None => ToneMap::new(0.0, 0.0, 0.0), - }; - let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - if clips_more(&sensor, &reference, &fitted, &plain) { - return refuse("clip"); - } - if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { - eprintln!("DNGTONE accepted"); - } - Some(look) -} - -/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain -/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the -/// camera's look, one that clips what neither does loses highlight detail. -fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { - let near_white = |tone: &ToneMap| { - let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); - n as f64 / sensor.data.len().max(1) as f64 - }; - let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; - near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE -} - -/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the -/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the -/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. -fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { - let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; - let (w, h) = (sensor.width as f64, sensor.height as f64); - let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; - let width = sensor.width.max(1); - for (i, p) in sensor.data.iter_mut().enumerate() { - let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); - let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; - *p = p.map(|v| v * gain); - } -} - -/// 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 -/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty. -const MIN_PREVIEW_FRACTION: f64 = 0.1; - -/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark -/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. -const DARK_FLOOR: (f32, f32) = (0.004, 0.001); - -/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's -/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits -/// that clip about 1% more than the camera does and passes the ones that clip like the camera). -const CLIP_TOLERANCE: f64 = 0.001; - -/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. -const MIN_STRUCTURE: f64 = 0.85; - -/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance -/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) -/// correlates better. -fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { - let (w, h) = (sensor.width, sensor.height); - if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { - return false; - } - let log = |img: &Rgb32f| -> Vec { - let l: Vec = - img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); - (0..w * h) - .map(|i| { - let (x, y) = (i % w, i / w); - let (mut sum, mut n) = (0.0, 0.0); - for yy in y.saturating_sub(2)..(y + 3).min(h) { - for xx in x.saturating_sub(2)..(x + 3).min(w) { - if let Some(v) = l.get(yy * w + xx) { - sum += v; - n += 1.0; - } - } - } - sum / f64::max(n, 1.0) - }) - .collect() - }; - let (a, b) = (log(sensor), log(reference)); - const M: usize = 3; - let corr = |dx: isize, dy: isize| -> f64 { - let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); - for y in M..h - M { - for x in M..w - M { - let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); - let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; - n += 1.0; - sa += u; - sb += v; - saa += u * u; - sbb += v * v; - sab += u * v; - } - } - let var = (saa - sa * sa / n) * (sbb - sb * sb / n); - if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } - }; - let at_zero = corr(0, 0); - let (mut best, mut shift) = (at_zero, (0isize, 0isize)); - for dy in -(M as isize)..=M as isize { - for dx in -(M as isize)..=M as isize { - let c = corr(dx, dy); - if c > best + 1e-9 { - (best, shift) = (c, (dx, dy)); - } - } - } - at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 -} - -/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s -/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), -/// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies( - raw: &RawImage, - bytes: &[u8], - transform: &CameraTransform, - size: usize, - min_fraction: f64, - lens: Option<&LensGeometry>, -) -> Option<(Rgb32f, Rgb32f, Vec)> { - let edge = (2 * size).max(384) as u32; - let decoded = crate::files::decode_raw_preview( - bytes, - lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, - None, - )?; - let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); - // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any - if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) - { - return None; - } - let mut reference = decoded.to_working(); - if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { - return None; - } - let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02; - if !matches(crop.width, crop.height, reference.width, reference.height) { - // Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio - if !matches(a.width, a.height, reference.width, reference.height) { - return None; - } - let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64); - let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize); - let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize); - if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height { - return None; - } - reference = reference.into_crop(x, y, w, h); - } - // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. - let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; - let mut sensor = sensor_proxy(raw, k, edge as usize)?; - let mut clipped = clip_mask(&sensor); - // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % - // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, - // kept below when it lines up with the JPEG better than the uncorrected one. - let warped = lens - .filter(|_| raw.format != RawFormat::Cr3) - .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); - let gain = 2f32.powf(transform.baseline_exposure as f32); - let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); - if raw.format == RawFormat::Cr3 { - // Orient the camera JPEG and the sensor identically before collecting pixel correspondences. - sensor = sensor.into_oriented(raw.orientation); - clipped = clipped.into_oriented(raw.orientation); - reference = reference.into_oriented(raw.orientation); - sensor.map_in_place(to_working); - // Canon's JPEG can be cropped differently from the sensor. Estimate only that common - // framing from edge directions, independently of the subsequent colour fit. - (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); - } - let mut sensor = fit(&sensor, size, size, Filter::Box); - let mut reference_small = reference_at(&reference, &sensor); - if let Some((warped, warped_clipped)) = warped { - let candidate = fit(&warped, size, size, Filter::Box); - let candidate_reference = reference_at(&reference, &candidate); - if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { - (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); - } - } - let reference = reference_small; - // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is - // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like - // the reference, so the mask pairs pixel for pixel. - let clipped: Vec = resize(&clipped, sensor.width, sensor.height, Filter::Box).data.iter().map(|p| p[0] > 0.0).collect(); - if raw.format != RawFormat::Cr3 { - sensor.map_in_place(to_working); - } - Some((sensor, reference, clipped)) -} - -/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it -/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's -/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs` -/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked -/// that the aspects agree within 2 %. -fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f { - resize(reference, sensor.width, sensor.height, Filter::Box) -} - -#[derive(Clone, Copy, Debug)] -struct Cr3Framing { - scale: f32, - /// Translation as a fraction of the oriented frame's width and height. - offset: [f32; 2], -} - -impl Cr3Framing { - const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] }; - - fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) { - let (w, h) = (w as f32, h as f32); - ((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1])) - } -} - -/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different -/// white balance and monotone camera tone; no target RGB values train this three-parameter fit. -/// A separate third of the edges must confirm a substantial improvement and a close match. -/// A weak, flat or unrelated preview leaves the sensor framing unchanged. -fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option { - if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() { - return None; - } - let sensor = fit(sensor, PROXY, PROXY, Filter::Box); - let (w, h) = (sensor.width, sensor.height); - if w < 16 || h < 16 { - return None; - } - let reference = fit(reference, w, h, Filter::Box); - if (reference.width, reference.height) != (w, h) { - return None; - } - let mut gradients = Rgb32f::new(w, h); - let mut edges = Vec::new(); - for y in 1..h - 1 { - for x in 1..w - 1 { - let derivative = |image: &Rgb32f| { - [ - luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]), - luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]), - ] - }; - let d = derivative(&sensor); - gradients.data[y * w + x] = [d[0], d[1], 0.0]; - let d = derivative(&reference); - let magnitude = d[0].hypot(d[1]); - let value = luminance_2020(reference.data[y * w + x]); - if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 { - edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0)); - } - } - } - if edges.len() < 192 { - return None; - } - let score = |framing: Cr3Framing, held_out: bool| -> Option { - let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize); - for &(x, y, target, magnitude, holdout) in &edges { - if holdout != held_out { - continue; - } - total += 1; - let (sx, sy) = framing.source(x, y, w, h); - if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 { - continue; - } - let sample = gradients.sample_bilinear(sx, sy); - let length = sample[0].hypot(sample[1]); - if !length.is_finite() || length <= 0.01 { - continue; - } - let k = f64::from(magnitude.min(0.25)); - agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude)); - weight += k; - valid += 1; - } - (valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight) - }; - let before = score(Cr3Framing::IDENTITY, false)?; - let mut best = (before, Cr3Framing::IDENTITY); - // At most 10% zoom and two proxy pixels of translation; a coarse search followed by one - // local refinement prevents unbounded optimisation on the photograph's contents. - for s in -10..=10 { - for tx in -4..=4 { - for ty in -4..=4 { - let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] }; - if let Some(value) = score(framing, false) - && value > best.0 - { - best = (value, framing); - } - } - } - } - let coarse = best.1; - for s in -5..=5 { - for tx in -4..=4 { - for ty in -4..=4 { - let framing = Cr3Framing { - scale: coarse.scale + s as f32 * 0.002, - offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32], - }; - if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 { - continue; - } - if let Some(value) = score(framing, false) - && value > best.0 - { - best = (value, framing); - } - } - } - } - let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?); - if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 { - return None; - } - if lightcraft_pipeline::profiling() { - eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1); - } - Some(best.1) -} - -/// The sensor proxy and its clip mask (same size), both moved to the camera JPEG's framing. -fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rgb32f, Rgb32f) { - let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return (sensor, clipped) }; - let apply = |image: &Rgb32f| { - let (w, h) = (image.width, image.height); - let mut aligned = Rgb32f::new(w, h); - for (i, pixel) in aligned.data.iter_mut().enumerate() { - let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); - *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { image.sample_bilinear(x, y) } else { [f32::NAN; 3] }; - } - aligned - }; - (apply(&sensor), apply(&clipped)) -} - -/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the -/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own -/// colour matrices, other files or unusable previews. -pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option> { - if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { - return None; - } - let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); - let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?; - let to_camera = transform.matrix.inverse()?; - // clipped sensor pixels don't record the scene's colour (see `fit_look`) - let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); - let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?; - Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) -} - -/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table) -/// fitted to pairs pooled from many photos. -pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option)> { - let matrix = fit_matrix(pairs)?; - Some((matrix, fit_hue_sat(pairs, &matrix))) -} - -/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of -/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without -/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct LensGeometry { - /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. - k: [f64; 4], - /// The optical centre, relative to the active area (as the opcode has it). - center: [f64; 2], -} - -pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option { - use lightcraft_raw::Opcode; - let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); - if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { - return None; - } - ops.into_iter().find_map(|op| match op { - Opcode::WarpRectilinear { planes, center } => { - let p = planes.get(1).or(planes.first())?; - let k = [p[0], p[1], p[2], p[3]]; - (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1])) - .then_some(LensGeometry { k, center: *center }) - } - _ => None, - }) -} - -/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both -/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. -fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { - let (w, h) = (a.width, a.height); - if (b.width, b.height) != (w, h) || w < 3 || h < 3 { - return -1.0; - } - let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::>(); - let high = |l: &[f64]| -> Vec { - (0..w * h) - .map(|i| { - let (x, y) = (i % w, i / w); - if x == 0 || y == 0 || x + 1 == w || y + 1 == h { - return f64::NAN; - } - let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; - l[i] - around.iter().map(|&j| l[j]).sum::() / 8.0 - }) - .collect() - }; - let (ha, hb) = (high(&lum(a)), high(&lum(b))); - let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); - if pairs.len() < 64 { - return -1.0; - } - let n = pairs.len() as f64; - let (ma, mb) = (pairs.iter().map(|p| p.0).sum::() / n, pairs.iter().map(|p| p.1).sum::() / n); - let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); - let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); - let r = cov / (va * vb).sqrt().max(1e-12); - if r.is_finite() { r } else { -1.0 } -} - -/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is -/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` -/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another -/// size (the demosaiced fallback of linear raws). -fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option { - let a = raw.active_area; - let c = raw.crop.clipped(a.width, a.height); - let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; - let (w, h) = (img.width, img.height); - if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { - return None; - } - let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); - let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); - let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); - let [k0, k1, k2, k3] = lens.k; - let kf = k as f64; - let (x0, y0) = (c.x as f64, c.y as f64); - let mut out = Rgb32f::new(w, h); - for (i, px) in out.data.iter_mut().enumerate() { - // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor - let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); - let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); - let r2 = dx * dx + dy * dy; - let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); - let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); - *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { - img.sample_bilinear(sx as f32, sy as f32) - } else { - [f32::NAN; 3] - }; - } - Some(out) -} - -fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { - Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { - Some(sensor) => sensor, - // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution. - None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => { - fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box) - } - None => return None, - }) -} - -/// A fit must cut the held-out squared error to below this share of the fallback's. -const MIN_IMPROVEMENT: f64 = 0.7; -/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public -/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed -/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve -/// cannot follow): visibly better renders that 0.055 rejected. -const MAX_HOLDOUT_RMS: f64 = 0.10; - -fn luma(p: [f64; 3]) -> f64 { - p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593 -} - -/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`). -fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] { - let scene = scene.map(|v| v.max(0.0)); - let y = luma(scene); - if y <= 0.0 { - return [0.0; 3]; - } - let o = f64::from(tone.apply(y as f32)); - let k = f64::from(tone.chroma_scale(o as f32)); - scene.map(|v| o + (v * o / y - o) * k) -} - -#[cfg(test)] -fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { - fit_pairs_with(sensor, reference, None) -} - -/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges). -fn local_contrast(image: &Rgb32f, i: usize) -> f32 { - let (w, h) = (image.width, image.height); - if w == 0 { - return f32::INFINITY; - } - let (x, y) = (i % w, i / w); - let (mut lo, mut hi) = (f32::INFINITY, 0f32); - for row in y.saturating_sub(1)..(y + 2).min(h) { - for col in x.saturating_sub(1)..(x + 2).min(w) { - let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p)); - lo = lo.min(l); - hi = hi.max(l); - } - } - if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY } -} - -/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights -/// (for the chroma curve); `None` when too few, or the photo has too little colour. With -/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio -/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated -/// colours (see [`EDGE_CONTRAST`]). -type Pair = ([f64; 3], [f64; 3]); -type Pairs = Vec; -fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { - if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { - return None; - } - let mut pairs = Vec::new(); - // Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only. - let mut bright = Vec::new(); - let mut colour = 0; - for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { - if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { - continue; - } - let y = luminance_2020(*output); - if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) { - continue; - } - if (0.015..=1.0).contains(&y) { - bright.push((input.map(f64::from), output.map(f64::from))); - } - let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) }; - if !colour_pair_above(*input, *output, floors) { - continue; - } - let min = output.iter().copied().fold(f32::INFINITY, f32::min); - let max = output.iter().copied().fold(0.0, f32::max); - colour += usize::from(max - min > min_chroma); - pairs.push((input.map(f64::from), output.map(f64::from))); - } - (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) -} - -/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. -fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { - colour_pair_above(input, output, (0.015, 0.004)) -} - -/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or -/// [`DARK_FLOOR`] for the tone-only fit of a DNG). -fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> bool { - input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) - && output.iter().all(|v| *v > clo && *v < 0.98) - && (ylo..0.85).contains(&luminance_2020(output)) -} - -/// Fit tone independently of colour eligibility when enough dark JPEG signal is available. -/// The original pairs and their split still own the colour fit and its acceptance gates. -/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and -/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise. -fn fit_tone_with_shadows( - fit: ToneFit, - pairs: &[([f64; 3], [f64; 3])], - sensor: &Rgb32f, - reference: &Rgb32f, - edge_limit: Option, - colour: impl Fn([f64; 3]) -> [f64; 3], -) -> Option { - let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y)); - let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect()); - let mut eligible = Vec::new(); - let (mut colour_index, mut extra_index) = (0, 0); - for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { - if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { - continue; - } - // About one 8-bit sRGB code above black: don't learn a flat black segment from - // JPEG compression. The sensor proxy averages many samples before this test. - if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5) - || !output.iter().all(|v| v.is_finite() && *v >= 0.0) - || !(0.0003..0.85).contains(&luminance_2020(*output)) - { - continue; - } - // A colour holdout must never enter tone training just because the wider sample set - // changes its index. Split the extra samples separately, keeping every colour index. - let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index }; - let held = *index % 3 == 0; - *index += 1; - let (x, y) = luminances(input.map(f64::from), output.map(f64::from)); - if x.is_finite() && x > 0.0 { - eligible.push((x, y, held)); - } - } - let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count(); - let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect(); - // Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank, - // so a single quantized grey also keeps the original curve. - if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION { - return original; - } - let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect(); - let Some(candidate) = fit.fit(training) else { return original }; - let error = |tone: &CameraTone| -> f64 { - held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum() - }; - // A wider histogram must also help independent dark pixels, not just redistribute the - // knots. The usual held-out gates still check the completed look on the colour pairs. - if let Some(old) = original { - let (before, after) = (error(&old), error(&candidate)); - if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}"); - } - if !after.is_finite() || after >= before * 0.99 { - return original; - } - } - Some(candidate) -} - -/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs. -fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { - let mut gram = [[0.0; 3]; 3]; - let mut cross = [[0.0; 3]; 3]; - for (i, (x, y)) in pairs.iter().enumerate() { - if i % 3 == 0 { - continue; - } - let (lx, ly) = (luma(*x), luma(*y)); - if lx <= 0.0 || ly <= 0.0 { - continue; - } - for row in 0..3 { - for col in 0..3 { - // Normalising by luminance prevents a camera S-curve from corrupting colour. - gram[row][col] += x[row] * x[col] / (lx * lx); - cross[row][col] += y[row] * x[col] / (ly * lx); - } - } - } - let trace: f64 = (0..3).map(|i| gram[i][i]).sum(); - let inverse = Mat3(gram).inverse()?; - let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::(); - if trace <= 0.0 || !condition.is_finite() || condition > 1e6 { - return None; - } - let regularization = trace * 1e-4; - for i in 0..3 { - gram[i][i] += regularization; - cross[i][i] += regularization; - } - let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?); - matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix) -} - -/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt -/// of [`search_ordered`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", -/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px -/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched -/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction -/// off). Away from edges: 0.049. Issue #232. -const EDGE_CONTRAST: f32 = 3.0; - -/// The look on all pixels (the search of [`search_ordered`] with [`TONE_FITS`]), as before -/// clipped pixels were left out. -#[cfg(test)] -fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - fit_pairs_ordered(sensor, reference, colour, TONE_FITS) -} - -/// The look from [`search_ordered`] without its attempt. -#[cfg(test)] -fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option { - search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false }) - .map(|(look, _)| look) -} - -/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is -/// used. Quantile matching comes first because it cannot produce flat steps; the -/// conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) its -/// slightly lower held-out error is what clears the gates, and a photo must not lose its look to -/// the change of fit. -const TONE_FITS: &[ToneFit] = &[ToneFit::Quantile, ToneFit::ConditionalMedian]; - -/// One attempt of the search: tone fit, the camera profile's colour or the photo's own, all pixels -/// or only those away from edges. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -struct Attempt { - tone: ToneFit, - profile: bool, - away_from_edges: bool, - /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. - dark: bool, -} - -/// The photo's look and the attempt that produced it: its own matrix (with and without a -/// hue/saturation table), or the given colour model (a camera profile's, in the sensor proxy's -/// space), each completed with a tone and chroma curve fitted to this photo. Per tone fit in -/// `order`: the camera profile's colour (when there is one), then the photo's own, as a different -/// picture style can make a camera profile fail the gates; each fitted on all pixels and, when that -/// fails the gates, once more on the pixels away from edges (same gates): there colour pairs stay -/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole -/// search runs with one tone fit before the next is tried, so the conditional-median fallback -/// accepts exactly what the previous releases accepted. -fn search_ordered( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: Option<(Mat3, Option)>, - order: &[ToneFit], - bright: &HighlightCheck, -) -> Option<(CameraLook, Attempt)> { - let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] }; - order.iter().find_map(|&tone| { - profiles.iter().find_map(|&profile| { - [false, true].into_iter().find_map(|away_from_edges| { - let attempt = Attempt { tone, profile, away_from_edges, dark: false }; - fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt)) - }) - }) - }) -} - -fn fit_attempt( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: &Option<(Mat3, Option)>, - attempt: Attempt, - bright: &HighlightCheck, -) -> Option { - let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright) -} - -fn fit_pairs_on( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: Option<(Mat3, Option)>, - edge_limit: Option, - tone_fit: ToneFit, - dark: bool, - highlights: &HighlightCheck, -) -> Option { - // A known colour model needs enough signal for tone fitting, not a scene rich enough to - // learn a new colour matrix. Still reject monochrome references. - let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; - let candidates = match colour { - Some(given) => vec![given], - None => { - let matrix = fit_matrix(&pairs)?; - vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)] - } - }; - // Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by - // error in gamma-encoded display values (closer to what is seen: in linear values a slightly - // missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear. - let mut best: Option<(f64, f64, usize, CameraLook)> = None; - for (matrix, hue_sat) in candidates { - let correction = hue_sat.as_ref().and_then(HueSat::new); - let colour = |x: [f64; 3]| { - let p = matrix.apply(x); - correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) - }; - let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue }; - let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() }; - if let Some(tone) = fit_chroma(&bright, &look) { - look.tone = tone; - } - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - if !highlights_hold(&look.tone, highlights, colour) { - continue; - } - let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0); - for (x, target) in pairs.iter().step_by(3) { - let corrected = displayed(colour(*x), &tone); - for c in 0..3 { - linear += (corrected[c] - target[c]).powi(2); - perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2); - samples += 1; - } - } - if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) { - best = Some((perceptual, linear, samples, look)); - } - } - let (_, after, samples, look) = best?; - let original_tone = ToneMap::new(0.0, 0.0, 0.0); - let before: f64 = pairs - .iter() - .step_by(3) - .map(|(x, target)| { - let original = displayed(*x, &original_tone); - (0..3).map(|c| (original[c] - target[c]).powi(2)).sum::() - }) - .sum(); - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{}{})", - (before / samples as f64).sqrt(), - (after / samples as f64).sqrt(), - if edge_limit.is_some() { ", away from edges" } else { "" }, - if tone_fit == ToneFit::ConditionalMedian { ", conditional-median tone" } else { "" } - ); - } - if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { - return None; - } - Some(look) -} - -/// Weight of the photo's own colour matrix in a [`fit_partial`] look; the rest is the identity (the -/// neutral fallback's generic camera ≈ sRGB colour). On the CC0 archive's proxies, with the -/// matrix at full weight 2 of the 23 colourful photos without a look came out more than 2° further -/// from the camera's hues than the neutral fallback, at half weight none (median hue error 9.6° with -/// the generic colour, which the tone and chroma curves make visible, 5.1° at half weight). -const PARTIAL_MATRIX_WEIGHT: f64 = 0.5; - -/// Smallest rank correlation of scene and camera JPEG luminance over a [`fit_partial`] look's pairs: -/// the JPEG must show the same picture. A look that only matches the brightness distribution of an -/// unrelated picture can still beat the dark neutral fallback pixel for pixel. On the CC0 archive's -/// proxies the accepted full looks have 0.55 or more, lens-corrected compacts without one 0.53–0.77, -/// three old Coolpix files whose raw decodes wrongly 0.07–0.27. -const PARTIAL_MIN_RANK_CORRELATION: f64 = 0.5; - -/// Spearman rank correlation of the scene's and the JPEG's luminance over `pairs`. -fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 { - let ranks = |values: Vec| { - let mut order: Vec = (0..values.len()).collect(); - order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); - let mut ranks = vec![0.0; values.len()]; - for (rank, i) in order.into_iter().enumerate() { - ranks[i] = rank as f64; - } - ranks - }; - let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); - let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); - let mean = (pairs.len() as f64 - 1.0) / 2.0; - let (covariance, variance) = - scene.iter().zip(&jpeg).fold((0.0, 0.0), |(c, v), (a, b)| (c + (a - mean) * (b - mean), v + (a - mean) * (a - mean))); - if variance > 0.0 { covariance / variance } else { 0.0 } -} - -/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without -/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order, -/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related. -fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 { - let ranks = |values: Vec| { - let mut order: Vec = (0..values.len()).collect(); - order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); - let mut ranks = vec![0.0; values.len()]; - let mut start = 0; - while start < order.len() { - let mut end = start + 1; - while end < order.len() && values[order[end]] == values[order[start]] { - end += 1; - } - let mean = (start + end - 1) as f64 / 2.0; - for &i in &order[start..end] { - ranks[i] = mean; - } - start = end; - } - ranks - }; - let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); - let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); - let mean = (pairs.len() as f64 - 1.0) / 2.0; - let (covariance, scene_variance, jpeg_variance) = scene - .iter() - .zip(&jpeg) - .fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean))); - if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 } -} - -/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of -/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs -/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered -/// 9–22 L* too dark), the chroma curve (the fallback kept about a third of the camera's -/// colourfulness), and, when the scene has enough colour to learn one (the full look's condition), -/// the photo's own colour matrix at [`PARTIAL_MATRIX_WEIGHT`]. No hue/saturation table. Tried on -/// all pixels and then, like the full look, on the pixels away from edges; used only when it cuts -/// the held-out squared error to below [`MIN_IMPROVEMENT`] of the neutral fallback's, in linear -/// and in gamma-encoded display values, and when the JPEG shows the same picture -/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly -/// closer to the camera JPEG than what the photo would get otherwise. -fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights)) -} - -fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option, highlights: &HighlightCheck) -> Option { - // as for a known colour model: enough signal for the tone, but no monochrome reference - let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; - let related = rank_correlation(&pairs); - if related < PARTIAL_MIN_RANK_CORRELATION { - if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look partial: luminance rank correlation {related:.3}, not the same picture"); - } - return None; - } - let spread = |y: &[f64; 3]| y.iter().copied().fold(f64::NEG_INFINITY, f64::max) - y.iter().copied().fold(f64::INFINITY, f64::min); - let colourful = pairs.iter().filter(|(_, y)| spread(y) > 0.05).count() >= pairs.len() / 20; - let matrix = match colourful.then(|| fit_matrix(&pairs)).flatten() { - Some(own) => Mat3(std::array::from_fn(|r| { - std::array::from_fn(|c| Mat3::IDENTITY.0[r][c] + PARTIAL_MATRIX_WEIGHT * (own.0[r][c] - Mat3::IDENTITY.0[r][c])) - })), - None => Mat3::IDENTITY, - }; - let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?; - let mut look = CameraLook { matrix, tone, hue_sat: None }; - if let Some(tone) = fit_chroma(&bright, &look) { - look.tone = tone; - } - let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) { - return None; - } - let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); - let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); - for (x, target) in pairs.iter().step_by(3) { - let (fallback, partial) = (displayed(*x, &neutral), displayed(matrix.apply(*x), &tone)); - for c in 0..3 { - before += (fallback[c] - target[c]).powi(2); - after += (partial[c] - target[c]).powi(2); - before_encoded += (encoded(fallback[c]) - encoded(target[c])).powi(2); - after_encoded += (encoded(partial[c]) - encoded(target[c])).powi(2); - samples += 1; - } - } - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look partial (tone{}{}) holdout RMS {:.5} -> {:.5} ({samples} channels{})", - if look.tone.chroma().iter().any(|k| *k != 1.0) { ", chroma" } else { "" }, - if matrix != Mat3::IDENTITY { ", damped matrix" } else { "" }, - (before / samples.max(1) as f64).sqrt(), - (after / samples.max(1) as f64).sqrt(), - if edge_limit.is_some() { ", away from edges" } else { "" } - ); - } - let better = - after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; - (samples > 0 && better).then_some(look) -} - -/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. -const GREY_SPREAD: f32 = 0.002; - -/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon -/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within -/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one. -fn monochrome(reference: &Rgb32f) -> bool { - let coloured = reference - .data - .iter() - .filter(|p| { - let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); - !p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD - }) - .count(); - reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len() -} - -/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera -/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo -/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker -/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve. -/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and -/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The -/// chroma curve stays neutral: the JPEG has no colourfulness to learn. -fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { - if !file_local_look(raw.format) { - return None; - } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; - if !monochrome(&reference) { - return None; - } - // Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one - let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); - let unclipped = without_clipped(&sensor, &clipped); - let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; - let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?; - let look = if dro { with_dro_off_tone(look) } else { look }; - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}", - raw.format, - look.tone, - if transform.matrix_is_fallback { "fallback" } else { "spectral" } - ); - } - Some(look) -} - -/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's -/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of -/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture -/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below -/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than -/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance). -fn fit_tone_only_with( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: Option<(Mat3, Option)>, - highlights: &HighlightCheck, -) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights)) -} - -/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas. -#[cfg(test)] -fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }) -} - -fn fit_tone_only_on( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: &Option<(Mat3, Option)>, - edge_limit: Option, - highlights: &HighlightCheck, -) -> Option { - // every pair counts: the JPEG has no colour to require - let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; - fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights) -} - -/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs. -/// Try only after every existing look fails. Keep the colour of the source transform, -/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG -/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights. -fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option { - if (sensor.width, sensor.height) != (reference.width, reference.height) - || sensor.data.len() != reference.data.len() - || clipped.len() != sensor.data.len() - || sensor.data.is_empty() - || sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite())) - || reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85) - { - return None; - } - let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count(); - if (dark as f64) < reference.data.len() as f64 * 0.9 { - return None; - } - // no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold - let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }; - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| { - let pairs: Pairs = sensor - .data - .iter() - .zip(&reference.data) - .enumerate() - .filter(|(i, (input, output))| { - // The same signal floors as the camera toe, without colour ratios or counts. - !clipped.get(*i).copied().unwrap_or(true) - && input.iter().all(|v| *v > 0.0001 && *v < 1.5) - && (0.0003..0.85).contains(&luminance_2020(**output)) - && edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit) - }) - .map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from))) - .collect(); - if pairs.len() < 256 { - return None; - } - let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?; - let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) { - if *clipped { - let target = luma(target.map(f64::from)); - let before = luma(displayed(input.map(f64::from), &neutral)); - let after = luma(displayed(input.map(f64::from), &tone)); - if !after.is_finite() || (after - target).abs() > (before - target).abs() { - return None; - } - } - } - Some(look) - }) -} - -fn fit_tone_only_pairs( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: &Option<(Mat3, Option)>, - edge_limit: Option, - pairs: &Pairs, - dark: bool, - highlights: &HighlightCheck, -) -> Option { - let kind = if dark { "near-black scene" } else { "black-and-white JPEG" }; - let related = rank_correlation_ties(pairs); - if related < PARTIAL_MIN_RANK_CORRELATION { - if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture"); - } - return None; - } - let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None)); - let correction = hue_sat.as_ref().and_then(HueSat::new); - let coloured = |x: [f64; 3]| { - let p = matrix.apply(x); - correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) - }; - let tone = if dark { - // Split this wider set once. The camera toe's separate colour/extra split belongs to - // the original colour pairs and would put some of these held-out pixels into training. - fit_tone( - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(), - )? - } else { - fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)? - }; - let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; - if !highlights_hold(&look.tone, highlights, coloured) { - return None; - } - let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); - let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); - for (x, target) in pairs.iter().step_by(3) { - let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target)); - before += (fallback - target).powi(2); - after += (toned - target).powi(2); - before_encoded += (encoded(fallback) - encoded(target)).powi(2); - after_encoded += (encoded(toned) - encoded(target)).powi(2); - samples += 1; - } - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", - (before / samples.max(1) as f64).sqrt(), - (after / samples.max(1) as f64).sqrt(), - if edge_limit.is_some() { ", away from edges" } else { "" } - ); - } - let better = - after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; - (samples > 0 && better).then_some(look) -} - -/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in. -fn to_prophoto() -> Mat3 { - PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) -} - -/// [`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 -/// its colour; in between the two are blended, so noisy shadows show no step. -const MIN_LUMINANCE_KEPT: f32 = 0.25; -const NO_LUMINANCE_KEPT: f32 = 0.15; - -/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to -/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between. -fn restore_weight(share: f32) -> f32 { - if !share.is_finite() { - return 0.0; - } - let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} - -/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes -/// hue and saturation only: luminance is restored, the camera tone curve owns it. -pub(crate) struct HueSat<'a> { - table: &'a HsvTable, - to: [[f32; 3]; 3], - from: [[f32; 3]; 3], -} - -impl<'a> HueSat<'a> { - pub fn new(table: &'a HsvTable) -> Option> { - let to = to_prophoto(); - Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() }) - } - - #[inline] - pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { - let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; - let out = mul(&self.from, self.table.apply(mul(&self.to, rgb))); - let (before, after) = (luminance_2020(rgb), luminance_2020(out)); - // restoring the luminance divides by the table's: near black, where noise makes some channels - // negative, the table can bring it close to 0, and the gain would turn the pixel white (#820) - if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) { - return rgb; - } - let w = restore_weight(after / before); - if w >= 1.0 { - return out.map(|v| v * before / after); - } - if w <= 0.0 { - return rgb; - } - std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) - } - - /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through - /// [`HsvTable::apply_lanes`], with the guards as selects. - #[inline] - pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { - let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { - let mut out = [[0.0f32; N]; 3]; - for (row, out) in m.iter().zip(&mut out) { - for l in 0..N { - out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; - } - } - out - }; - let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); - let mut restored = rgb; - for l in 0..N { - let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); - let (before, after) = (luminance_2020(input), luminance_2020(out)); - let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); - let w = restore_weight(after / before); - for (c, restored) in restored.iter_mut().enumerate() { - let full = out[c] * before / after; - let blend = input[c] + (full - input[c]) * w; - // (`w >= 1` and `w <= 0` exclude each other) - restored[l] = if untouched | (w <= 0.0) { - input[c] - } else if w >= 1.0 { - full - } else { - blend - }; - } - } - restored - } -} - -/// HSV hue (degrees) and saturation; `None` for black or non-finite colours. -fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> { - let max = p[0].max(p[1]).max(p[2]); - let min = p[0].min(p[1]).min(p[2]); - if !(max.is_finite() && min.is_finite()) || max <= 0.0 { - return None; - } - let d = max - min; - if d <= 0.0 { - return Some((0.0, 0.0)); - } - let h = if max == p[0] { - ((p[1] - p[2]) / d).rem_euclid(6.0) - } else if max == p[1] { - (p[2] - p[0]) / d + 2.0 - } else { - (p[0] - p[1]) / d + 4.0 - }; - Some((h * 60.0, d / max)) -} - -/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns -/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately. -const TABLE_HUES: usize = 72; -const TABLE_SATS: usize = 5; -/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward -/// orange but bright yellow-greens toward green, which one shift per hue averages away. -const TABLE_VALS: usize = 5; -/// Kernel widths around each table node (hue in degrees, saturation, encoded value). -const KERNEL_HUE: f64 = 2.5; -const KERNEL_SAT: f64 = 0.15; -const KERNEL_VAL: f64 = 0.12; -/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity. -const SHRINK_WEIGHT: f64 = 5.0; - -/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue, -/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples. -/// Saturation 0 stays identity, so neutrals are never tinted. -fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option { - let to = to_prophoto(); - let samples: Vec<(f64, f64, f64, f64, f64)> = pairs - .iter() - .enumerate() - .filter(|(i, _)| i % 3 != 0) - .filter_map(|(_, (x, y))| { - let p = to.apply(matrix.apply(*x)); - let (hp, sp) = hue_saturation(p)?; - let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32)); - let (ht, st) = hue_saturation(to.apply(*y))?; - // hue is meaningless near neutral - if sp < 0.08 || st < 0.02 { - return None; - } - let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0); - let log_scale = (st / sp).ln().clamp(-0.7, 0.7); - Some((hp, sp.min(1.0), value, shift, log_scale)) - }) - .collect(); - if samples.len() < 64 { - return None; - } - // Samples by table hue step: a node only looks at hues within its kernel's reach. - let step = 360.0 / TABLE_HUES as f64; - let mut by_hue: Vec> = vec![Vec::new(); TABLE_HUES]; - for sample in samples { - if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) { - bin.push(sample); - } - } - let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1; - use rayon::prelude::*; - let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS) - .into_par_iter() - .map(|index| { - let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS); - if s == 0 { - return [0.0, 1.0, 1.0]; - } - let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64); - let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0); - for offset in 0..=2 * reach { - let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue }; - for &(hp, sp, vp, dh, ls) in bin { - let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0; - if dhue.abs() > 4.0 * KERNEL_HUE { - continue; - } - let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2); - let k = (-0.5 * d2).exp() * sp; - weight += k; - shift += k * dh; - log_scale += k * ls; - } - } - if weight <= 0.0 { - return [0.0, 1.0, 1.0]; - } - let shrink = 1.0 / (weight + SHRINK_WEIGHT); - [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0] - }) - .collect(); - data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable { - hue_divisions: TABLE_HUES, - sat_divisions: TABLE_SATS, - val_divisions: TABLE_VALS, - data, - srgb_value: true, - }) -} - -/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node -/// keeps half of its estimate. -const CHROMA_KERNEL: f64 = 0.08; -const CHROMA_SHRINK: f64 = 2.0; - -/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds -/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as -/// the distance from neutral of luminance-normalised RGB. -fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option { - let correction = look.hue_sat.as_ref().and_then(HueSat::new); - let scene = |x: &[f64; 3]| { - let p = look.matrix.apply(*x); - correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) - }; - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let predict = |x: &[f64; 3]| displayed(scene(x), &tone); - let chroma = |p: [f64; 3]| { - let y = luma(p); - (y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt()) - }; - let samples: Vec<(f64, f64, f64)> = pairs - .iter() - .enumerate() - .filter(|(i, _)| i % 3 != 0) - .filter_map(|(_, (x, y))| { - let p = predict(x); - let (cp, cy) = (chroma(p)?, chroma(*y)?); - (cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln()))) - }) - .collect(); - if samples.len() < 64 { - return None; - } - let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N]; - for (j, node) in curve.iter_mut().enumerate() { - let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64; - let (mut weight, mut sum) = (0.0, 0.0); - for &(o, cp, log_ratio) in &samples { - let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp; - weight += k; - sum += k * log_ratio; - } - *node = (sum / (weight + CHROMA_SHRINK)).exp() as f32; - } - let fitted = look.tone.with_chroma(curve)?; - let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0); - let error = |map: &ToneMap| -> f64 { - pairs - .iter() - .step_by(3) - .map(|(x, y)| { - let p = displayed(scene(x), map); - (0..3).map(|c| (p[c] - y[c]).powi(2)).sum::() - }) - .sum() - }; - let (before, after) = (error(&tone), error(&with)); - if lightcraft_pipeline::profiling() { - eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}"); - } - (after.is_finite() && after < before).then_some(fitted) -} - -/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum ToneFit { - /// [`fit_tone`]: no flat steps, no pixel-exact registration needed. - Quantile, - /// [`fit_tone_conditional`]: the earlier fit, kept as the fallback. - ConditionalMedian, -} - -impl ToneFit { - fn fit(self, pairs: Vec<(f64, f64)>) -> Option { - match self { - ToneFit::Quantile => fit_tone(pairs), - ToneFit::ConditionalMedian => fit_tone_conditional(pairs), - } - } -} - -/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel -/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped -/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped -/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can -/// leave them out. -fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs { - if sensor.data.len() != reference.data.len() { - return Vec::new(); - } - sensor - .data - .iter() - .zip(&reference.data) - .filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85) - .map(|(x, y)| (x.map(f64::from), y.map(f64::from))) - .collect() -} - -/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the -/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are -/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be -/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance -/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very -/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial -/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of -/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0. -/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`]. -const MIN_TONE_RANGE: f32 = 1.2; - -/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also -/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does -/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG -/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted -/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed -/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has -/// 0.93. -const NARROW_TONE_RANGE: f32 = 1.5; - -/// What a narrow tone curve is checked against ([`highlights_hold`]). -struct HighlightCheck { - /// The frame's [`highlights`]. - pairs: Pairs, - /// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the - /// fit, so the check looks at it lowered. - dro: bool, - /// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that - /// fit's gates do, since the JPEG has no colour to match. - grey: bool, -} - -/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps -/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further -/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get -/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than -/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels. -fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool { - let knots = tone.knots(); - if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE { - return true; - } - // the curve as the photo will get it - let used = if check.dro { dro_off(tone) } else { *tone }; - let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - let (mut look, mut fallback) = (0.0, 0.0); - for (x, target) in &check.pairs { - let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral)); - if check.grey { - look += (luma(rendered) - luma(*target)).powi(2); - fallback += (luma(base) - luma(*target)).powi(2); - continue; - } - for c in 0..3 { - look += (rendered[c] - target[c]).powi(2); - fallback += (base[c] - target[c]).powi(2); - } - } - let hold = look.is_finite() && look <= fallback; - if !hold && lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})"); - } - hold -} - -/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs -/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th -/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the -/// scene to the same quantile of the JPEG; matching the two distributions needs no pixel-exact -/// registration and is increasing by construction. -/// -/// Binning the pairs by scene luminance and taking the JPEG's median per bin instead (followed by -/// isotonic pooling) fails on real files: camera JPEGs are lens-corrected and sharpened, so on a -/// 96 px proxy of a busy scene neighbouring pairs disagree by more than the gap between bins. -/// The conditional medians then zig-zag, pooling turns them into flat steps (13 of 31 segments -/// on a Z 6II forest, issue #475) that render as posterised patches, and regression toward the -/// mean flattens both ends of the curve (lifted blacks, dull highlights). -fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { - if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { - return None; - } - let mut xs: Vec = pairs.iter().map(|p| p.0).collect(); - let mut ys: Vec = pairs.iter().map(|p| p.1).collect(); - xs.sort_by(f64::total_cmp); - ys.sort_by(f64::total_cmp); - let n = xs.len(); - let mut knots = [[0.0; 2]; 32]; - for (i, knot) in knots.iter_mut().enumerate() { - let (a, b) = (i * n / 32, (i + 1) * n / 32); - let mid = a + (b - a) / 2; - *knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32]; - } - if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { - return None; - } - CameraTone::new(knots) -} - -fn median(values: &mut [f64]) -> Option { - values.sort_by(f64::total_cmp); - values.get(values.len() / 2).copied() -} - -/// The earlier tone fit, kept as the fallback of [`ToneFit`]: the JPEG's median per equal-population -/// bin of scene luminance, then pooled-adjacent-violators isotonic regression. It follows the -/// per-pixel pairs closely when the two images are registered, which is why it can clear the -/// acceptance gates where [`fit_tone`] just misses them, but on lens-corrected JPEGs of busy scenes -/// the pooling leaves flat steps (issue #475). -fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { - if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { - return None; - } - pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut knots = [[0.0; 2]; 32]; - for (i, knot) in knots.iter_mut().enumerate() { - let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?; - let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect(); - let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect(); - *knot = [median(&mut xs)? as f32, median(&mut ys)? as f32]; - } - if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { - return None; - } - // Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial. - let mut blocks: Vec<(f32, usize)> = Vec::new(); - for knot in knots { - blocks.push((knot[1], 1)); - while blocks.len() >= 2 { - let (a, an) = *blocks.get(blocks.len() - 2)?; - let (b, bn) = *blocks.last()?; - if a <= b { - break; - } - blocks.truncate(blocks.len() - 2); - blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn)); - } - } - let mut i = 0; - for (y, n) in blocks { - for knot in knots.get_mut(i..i + n)? { - knot[1] = y; - } - i += n; - } - CameraTone::new(knots) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn steep_toe_scene() -> (Rgb32f, Rgb32f) { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - // A broad scene with a steep camera toe below the old JPEG floor of 0.015. - // Permute brightness so the held-out pixels span the same range independently. - let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25; - let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025]; - *src = rgb.map(|v| v * y / luminance_2020(rgb)); - let display = 3.0 * y * (y / (y + 0.02)).powi(2); - *dst = src.map(|v| v * display / y); - } - (sensor, reference) - } - - #[test] - fn camera_tone_follows_the_jpegs_deep_shadows() { - let (sensor, reference) = steep_toe_scene(); - let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap(); - let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); - assert!(dark.len() > 256); - let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; - assert!(error < 0.001, "deep-shadow luminance error {error}"); - assert_eq!(look.matrix, Mat3::IDENTITY); - } - - #[test] - fn colour_holdouts_stay_out_of_the_wider_tone_training() { - let (sensor, reference) = steep_toe_scene(); - let fit = |reference: &Rgb32f| { - let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap(); - fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() - }; - let expected = fit(&reference); - let mut changed = reference.clone(); - let mut index = 0; - for (x, y) in sensor.data.iter().zip(&mut changed.data) { - if colour_pair(*x, *y) { - if index % 3 == 0 { - *y = y.map(|v| v * 1.1); - assert!(colour_pair(*x, *y)); - } - index += 1; - } - } - assert_eq!(fit(&changed), expected); - } - - #[test] - fn camera_toe_is_robust_to_shadow_noise() { - let (sensor, clean) = steep_toe_scene(); - let mut noisy = clean.clone(); - for (i, p) in noisy.data.iter_mut().enumerate() { - if luminance_2020(*p) < 0.015 { - // Quantized JPEG levels with bounded noise and occasional compression outliers. - let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004; - *p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003); - if i % 23 == 0 { - *p = [0.014; 3]; - } - } - } - let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap(); - let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); - let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; - assert!(error < 0.001, "noisy shadow luminance error {error}"); - assert_eq!(look.tone.apply(0.0), 0.0); - assert_eq!(look.matrix, Mat3::IDENTITY); - for i in 1..1000 { - assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0)); - } - } - - #[test] - fn camera_toe_requires_lower_independent_dark_error() { - for holdout_matches_original in [true, false] { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 }; - *src = [y; 3]; - // Midtones teach the original slope of 3. Dark training teaches 0.3, - // but the independent dark holdout can agree with either slope. - let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 }; - *dst = [y * slope; 3]; - assert_eq!(colour_pair(*src, *dst), i >= 600); - } - let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap(); - let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); - // Both sample sets start at a multiple of three, so this split matches - // the helper's separate colour and extra-sample indices. - let candidate = fit_tone( - sensor - .data - .iter() - .zip(&reference.data) - .enumerate() - .filter(|(i, _)| i % 3 != 0) - .map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from)))) - .collect(), - ) - .unwrap(); - let held: Pairs = - sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); - assert_eq!(held.len(), 200); - assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y)))); - assert!(rank_correlation_ties(&held) > 0.99); - let error = |tone: &CameraTone| { - held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::() - }; - assert_ne!(candidate, original); - let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap(); - if holdout_matches_original { - assert!(error(&candidate) > error(&original) + 0.1); - assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve"); - } else { - assert!(error(&candidate) < error(&original) * 0.99); - assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate"); - } - let mut previous = 0.0; - assert_eq!(selected.apply(0.0), 0.0); - for i in 0..10_000 { - let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0)); - assert!(y.is_finite() && y >= previous && y <= 1.0); - previous = y; - } - assert_eq!(selected.apply(f32::NAN), 0.0); - assert_eq!(selected.apply(f32::INFINITY), 0.0); - } - } - - #[test] - fn unreliable_dark_pairs_keep_the_original_tone_and_colour() { - let (sensor, reference) = steep_toe_scene(); - let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect(); - let mut excluded = sensor.clone(); - for &i in &dark { - excluded.data[i] = [f32::NAN; 3]; - } - let colour = Some((Mat3::IDENTITY, None)); - let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap(); - for case in 0..6 { - let (mut x, mut y) = (sensor.clone(), reference.clone()); - for (j, &i) in dark.iter().enumerate() { - match case { - 0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal - 1 => x.data[i] = [0.00001; 3], // below the sensor noise floor - 2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs - 3 => x.data[i] = [0.003; 3], // no scene luminance spread - 4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe - 5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise - _ => {} - } - } - let look = fit_pairs_with(&x, &y, colour.clone()).unwrap(); - assert_eq!(look.tone, original.tone, "case {case}"); - assert_eq!(look.matrix, original.matrix, "case {case}"); - assert!(look.hue_sat.is_none()); - } - } - - #[test] - fn camera_jpeg_matches_the_sensor_proxy_size_exactly() { - // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy - // it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy. - let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box); - assert_eq!((sensor.width, sensor.height), (192, 128)); - let jpeg = Rgb32f::new(384, 257); - // what fitting gave before: a pixel short, so every pair was rejected - assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191); - let r = reference_at(&jpeg, &sensor); - assert_eq!((r.width, r.height), (192, 128)); - // and at the single-photo proxy size, where fitting happened to land on the same size - let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box); - let r = reference_at(&Rgb32f::new(192, 129), &sensor); - assert_eq!((r.width, r.height), (sensor.width, sensor.height)); - } - - #[test] - fn linear_arw_gets_a_sensor_proxy_without_demosaicing() { - use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect}; - let mut raw = RawImage { - format: RawFormat::Arw, - width: 32, - height: 32, - cpp: 3, - data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)), - cfa: None, - bits: 16, - black: BlackLevel::uniform(0.0), - white: vec![1.0], - active_area: Rect::new(0, 0, 32, 32), - crop: Rect::new(0, 0, 32, 32), - orientation: Orientation::from_exif(1), - color: ColorData::default(), - wb_multipliers: Some([1.0; 3]), - linearized: true, - pixel_aspect: 1.0, - opcodes: OpcodeLists::default(), - metadata: lightcraft_meta::Metadata::default(), - lens_metadata: Default::default(), - }; - let proxy = sensor_proxy(&raw, 2, 384).unwrap(); - assert_eq!((proxy.width, proxy.height), (32, 32)); - assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]); - raw.data = RawData::F32(Vec::new()); - assert!(sensor_proxy(&raw, 2, 384).is_none()); - } - #[test] - fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(64, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.05 + (i % 31) as f32 * 0.017; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); - } - let original = sensor.clone(); - let fit = fit_pairs(&sensor, &reference).unwrap(); - assert_eq!(sensor.data, original.data); - let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); - let error: f64 = sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| { - let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 - }) - .sum::() - / sensor.data.len() as f64; - assert!(error.sqrt() < 0.025, "{error}"); - // The colour transform is homogeneous; tone mapping happens only after exposure. - let p = fit.matrix.apply([2.0, 2.0, 2.0]); - assert!(luma(p) > 1.0); - assert!(tone.apply(0.2) < tone.apply(0.4)); - } - #[test] - fn accepts_a_much_better_fit_despite_local_camera_processing() { - // The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12 - // per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback. - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(64, 64); - let mut reference = sensor.clone(); - let mut seed = 0x2545_f491_u32; - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.05 + (i % 31) as f32 * 0.017; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - let noise = (seed % 2001) as f32 / 1000.0 - 1.0; - (v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97) - }); - } - assert!(fit_pairs(&sensor, &reference).is_some()); - } - - /// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime - /// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most - /// of the gap, keep neutrals neutral and keep luminance. - #[test] - fn hue_table_follows_a_hue_dependent_camera_rendering() { - let to = to_prophoto(); - let from = to.inverse().unwrap(); - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - let mut lime = Vec::new(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.05 + (i % 29) as f32 * 0.02; - // mostly greys and mild colours, plus a patch of saturated yellow-green - *src = if i % 7 == 0 { - [ev * 0.75, ev, ev * 0.2] - } else { - [ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)] - }; - let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap(); - // the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation - let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0); - let mut p = to.apply(src.map(f64::from)).map(|v| v as f32); - let table = HsvTable { - hue_divisions: 1, - sat_divisions: 2, - val_divisions: 1, - data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]], - srgb_value: false, - }; - p = table.apply(p); - let p = from.apply(p.map(f64::from)).map(|v| v as f32); - let scale = luminance_2020(*src) / luminance_2020(p); - let p = p.map(|v| v * scale); - let y = luminance_2020(p); - *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); - if i % 7 == 0 { - lime.push(i); - } - } - let fit = fit_pairs(&sensor, &reference).unwrap(); - let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted"); - let hue_sat = HueSat::new(table).unwrap(); - let hue_error = |with_table: bool| { - lime.iter() - .map(|&i| { - let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32); - let p = if with_table { hue_sat.apply(p) } else { p }; - let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap(); - let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap(); - ((h - t + 180.0).rem_euclid(360.0) - 180.0).abs() - }) - .sum::() - / lime.len() as f64 - }; - let (before, after) = (hue_error(false), hue_error(true)); - assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°"); - // neutrals pass through and luminance is kept - let grey = [0.3, 0.3, 0.3]; - assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey)); - let green = [0.2, 0.4, 0.05]; - assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5); - } - - /// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve), - /// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright - /// warm-tinted rock renders white instead of cream. - #[test] - fn chroma_curve_follows_highlight_bleaching() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let camera_chroma = |o: f32| 1.3 - 1.1 * o; - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.02 + (i % 37) as f32 * 0.03; - *src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - let o = 1.0 - (-2.5 * y).exp(); - let k = camera_chroma(o); - *dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999)); - } - let fit = fit_pairs(&sensor, &reference).unwrap(); - // relative to the matrix, which already carries the average colourfulness - let chroma = fit.tone.chroma(); - assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}"); - // and the rendered highlights land on the camera's, far closer than without the curve - let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); - let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0); - let highlight_error = |map: &ToneMap| -> f64 { - sensor - .data - .iter() - .zip(&reference.data) - .filter(|(_, y)| luminance_2020(**y) > 0.7) - .map(|(x, y)| { - let p = displayed(fit.matrix.apply(x.map(f64::from)), map); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() - }) - .sum() - }; - let (a, b) = (highlight_error(&without), highlight_error(&with)); - assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}"); - } - - /// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that - /// colour model (a camera profile) keeps it, fitting only its own tone. - #[test] - fn profile_colour_is_pooled_and_then_used_as_given() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let photo = |seed: usize, strength: f32| { - let mut sensor = Rgb32f::new(64, 48); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let i = i + seed * 7; - let ev = 0.04 + (i % 23) as f32 * 0.02; - *src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - // each photo has its own tone (DRO, picture style) - *dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y); - } - (sensor, reference) - }; - let mut pool = Vec::new(); - for seed in 0..4 { - let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); - pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); - } - let (matrix, _) = fit_profile(&pool).unwrap(); - // the camera's colours (luminance-normalised: the tone curve sets brightness) - for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] { - let (a, b) = (matrix.apply(x), known.apply(x)); - let (la, lb) = (luma(a), luma(b)); - assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}"); - } - let (sensor, reference) = photo(9, 3.5); - let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); - assert_eq!(look.matrix, matrix); - assert!(look.hue_sat.is_none()); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error = sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| { - let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 - }) - .sum::() - / sensor.data.len() as f64; - assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt()); - } - - #[test] - fn profile_fits_tone_without_learning_colour_from_a_dull_scene() { - let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]); - let mut sensor = Rgb32f::new(64, 48); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - *src = [0.02 + (i % 997) as f32 * 0.00045; 3]; - let p = matrix.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); - } - assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix"); - let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); - assert_eq!(look.matrix, matrix); - reference.map_in_place(|p| [luminance_2020(p); 3]); - assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected"); - } - - #[test] - fn rejected_profile_preserves_a_usable_per_photo_fit() { - let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); - let mut sensor = Rgb32f::new(64, 64); - let mut reference = sensor.clone(); - let mut seed = 0x2545_f491_u32; - for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { - *src = std::array::from_fn(|_| { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - 0.05 + (seed % 1000) as f32 * 0.0003 - }); - let p = matrix.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999)); - } - let per_photo = fit_pairs(&sensor, &reference).unwrap(); - let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap(); - assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit"); - assert_eq!(look.tone, per_photo.tone); - } - - #[test] - fn hue_sat_passes_black_and_non_finite_pixels_through() { - let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let hue_sat = HueSat::new(&table).unwrap(); - assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]); - let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]); - assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1); - assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); - } - - #[test] - fn hue_sat_never_turns_near_black_noise_white() { - // a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels - let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let hue_sat = HueSat::new(&table).unwrap(); - let mut seed = 0x9e37_79b9_7f4a_7c15u64; - let mut next = || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - (seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0 - }; - let mut worst = 0.0f32; - for _ in 0..200_000 { - let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()]; - if luminance_2020(rgb) <= 0.0 { - continue; - } - let out = hue_sat.apply(rgb); - let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs())); - worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale); - } - assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); - } - - /// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, - /// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. - #[test] - fn hue_sat_lanes_match_one_by_one_bit_for_bit() { - let mut seed = 0x2545_f491_4f6c_dd1du64; - let mut next = move || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - seed - }; - let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; - let fitted = (0..72 * 5 * 5) - .map(|_| { - let r = next(); - [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] - }) - .collect(); - let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; - let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; - let mut colours = Vec::new(); - for a in special { - for b in special { - colours.extend(special.map(|c| [a, b, c])); - } - } - for _ in 0..60_000 { - let r = next(); - let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; - colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); - } - for table in [fitted, saturating] { - let hue_sat = HueSat::new(&table).unwrap(); - for chunk in colours.chunks(16) { - let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); - let out = hue_sat.apply_lanes(rgb); - for (l, p) in chunk.iter().enumerate() { - let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); - assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); - } - } - } - } - - #[test] - fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { - assert_eq!(restore_weight(0.0), 0.0); - assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0); - assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0); - assert_eq!(restore_weight(1.0), 1.0); - assert_eq!(restore_weight(f32::NAN), 0.0); - // monotonic and without a step: neighbouring shares 0.001 apart differ by little - let mut last = 0.0f32; - for i in 0..=400 { - let w = restore_weight(i as f32 * 0.001); - assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001); - last = w; - } - } - - #[test] - fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() { - // the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT - let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let hue_sat = HueSat::new(&table).unwrap(); - let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; - for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] { - let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb))); - let (before, after) = (luminance_2020(rgb), luminance_2020(out)); - assert!(after >= MIN_LUMINANCE_KEPT * before); - assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after)); - } - } - - fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { - let area = lightcraft_raw::Rect::new(0, 0, 2, 2); - RawImage { - format, - width: 2, - height: 2, - cpp: 1, - data: lightcraft_raw::RawData::U16(vec![0; 4]), - cfa: None, - bits: 14, - black: Default::default(), - white: vec![16383.0], - active_area: area, - crop: area, - orientation: Default::default(), - color, - wb_multipliers: Some([2.0, 1.0, 1.5]), - linearized: false, - pixel_aspect: 1.0, - opcodes: Default::default(), - metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, - lens_metadata: Default::default(), - } - } - - /// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain - /// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. - #[test] - fn lens_geometry_reads_the_first_rectilinear_warp() { - use lightcraft_raw::Opcode; - let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { - planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], - center: c, - }; - assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); - let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; - assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); - assert!(lens_geometry(&[]).is_none()); - assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); - assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); - let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; - p[0] = 1.0; - p[18] = 1e6; - let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; - assert!(lens_geometry(&[chain, single]).is_none()); - } - - /// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp - /// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside - /// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. - #[test] - fn the_sensor_proxy_follows_the_cameras_lens_warp() { - let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); - raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); - raw.crop = raw.active_area; - // a 1/4 proxy whose pixels hold their own coordinates - let mut img = Rgb32f::new(16, 12); - for (i, p) in img.data.iter_mut().enumerate() { - *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; - } - let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; - let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); - assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); - // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor - let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); - assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); - let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); - assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); - assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); - } - - /// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected - /// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every - /// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. - #[test] - fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(entries) = std::fs::read_dir(&dir) else { - eprintln!("skip: no corpus at {}", dir.display()); - return; - }; - let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); - paths.sort(); - let (mut checked, mut gain) = (0, 0.0); - for path in paths { - let Ok(bytes) = std::fs::read(&path) else { continue }; - let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; - let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; - if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { - continue; - } - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = - (proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) - else { - continue; - }; - let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); - eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); - // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) - assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); - gain += after - before; - checked += 1; - } - eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); - assert!(checked == 0 || gain > 0.0); - } - - /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit - /// is tried. - #[test] - fn a_files_own_matrices_come_first() { - let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); - let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| panic!("no JPEG fit for a raw with its own matrices"), - |_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"), - |_, _| panic!("no dark tone fit for a raw with its own matrices"), - ); - assert_eq!(raw.color, color); - assert!(!t.matrix_is_fallback && look.is_none()); - } - - /// Steps 2 and 3 before step 4: an accepted JPEG fit (with or without a camera profile) is kept for a model that - /// has spectral matrices, and the spectral matrices are not written. Photos that had a fitted look before the - /// spectral table render exactly as they did. - #[test] - fn an_accepted_jpeg_fit_comes_before_the_spectral_matrices() { - for (make, model) in [("SONY", "ILCE-7M4"), ("Canon", "Canon EOS 600D")] { - assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}"); - let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default()); - let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()); - let (xy, t, look) = starting_colour_with( - &mut raw, - |raw, t| { - assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); - (Some(fitted.clone()), false) - }, - |_, _| panic!("no black-and-white JPEG fit after an accepted fit"), - |_, _| panic!("no dark tone fit after an accepted fit"), - ); - assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); - assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); - assert!(t.matrix_is_fallback, "{model}"); - assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); - } - } - - /// Step 4: when the JPEG fit fails (here: no camera JPEG at all), a model with a spectral row takes its matrices, - /// whether or not it has a camera profile (the ILCE-7M4 has a bundled one). - #[test] - fn a_failed_jpeg_fit_falls_back_to_the_spectral_matrices() { - assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); - let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); - let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[], None, None); - assert!(look.is_none()); - assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); - assert!(!t.matrix_is_fallback); - - let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = - starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None); - assert!(look.is_none()); - assert_eq!(raw.color.illuminant, [17, 21]); - assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); - assert!(!t.matrix_is_fallback); - assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); - // white-balanced white stays white - let w = t.matrix.apply([1.0; 3]); - assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); - } - - /// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the - /// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise - /// (issue #577). The black-and-white fit sees that transform. - #[test] - fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() { - let toned = CameraLook { - matrix: Mat3::IDENTITY, - tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone, - hue_sat: None, - }; - let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, true), - |raw, t| { - assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in"); - Some(toned.clone()) - }, - |_, _| panic!("an accepted monochrome look keeps its tone"), - ); - assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top"); - assert!(!t.matrix_is_fallback); - assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); - - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, true), - |_, t| { - assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour"); - Some(toned.clone()) - }, - |_, _| panic!("an accepted monochrome look keeps its tone"), - ); - assert!(look.is_some() && t.matrix_is_fallback); - assert_eq!(raw.color, lightcraft_raw::ColorData::default()); - } - - /// Step 5: a camera outside the table whose fit fails keeps the neutral fallback. - #[test] - fn a_camera_outside_the_table_keeps_the_fallback() { - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[], None, None); - assert!(look.is_none() && t.matrix_is_fallback); - assert_eq!(raw.color, lightcraft_raw::ColorData::default()); - // without a model there is no table row - let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); - raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback); - } - - /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is - /// accepted keeps it and its colour data; one whose fit fails takes its spectral matrices when its model has - /// them; a DNG keeps its own matrices. White balance stays relative to the as-shot look for every raw without - /// matrices of its own. - #[test] - fn corpus_colour_precedence() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { - let Ok(bytes) = std::fs::read(dir.join(name)) else { - lightcraft_testkit::skip!(Corpus, "{name} absent"); - continue; - }; - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - let lens = lens_geometry(&raw.opcodes.list3); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let own = lightcraft_raw::color::has_matrix(&raw.color); - let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); - let measured = lightcraft_raw::spectral::find(make, model).is_some(); - let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); - let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None); - let spectral = !own && fitted.is_none() && measured; - eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); - assert_eq!(raw.color != before, spectral, "{name}"); - assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); - // white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin, - // through the spectral matrices or its presets, whichever look it got (issue #730) - assert_eq!(info.relative_wb, !own && !covered, "{name}"); - assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); - if covered && !own { - assert!( - (2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0, - "{name}: {info:?}" - ); - } - // the same as the catalog decides from the probe (format and camera) - let probe = crate::files::probe_bytes(name, &bytes).unwrap(); - let mut p = lightcraft_catalog::Photo::new( - lightcraft_catalog::PhotoId(1), - lightcraft_catalog::Source::File { path: name.into() }, - name, - &probe.format, - 1, - 1, - "", - ); - p.kind = probe.kind; - p.meta = probe.meta.clone(); - assert_eq!(p.relative_wb(), info.relative_wb, "{name}"); - assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); - } - } - - #[test] - fn supported_raws_get_a_file_local_look() { - // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) - let local = [ - RawFormat::Arw, - RawFormat::Nef, - RawFormat::Nrw, - RawFormat::Rw2, - RawFormat::Raf, - RawFormat::Cr3, - RawFormat::Cr2, - RawFormat::Pef, - RawFormat::Srw, - RawFormat::Orf, - RawFormat::Mrw, - RawFormat::Erf, - ]; - assert!(local.into_iter().all(file_local_look)); - // DNG carries its own colour model - assert!(!file_local_look(RawFormat::Dng)); - } - - #[test] - fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() { - let (w, h) = (192, 128); - let mut sensor = Rgb32f::new(w, h); - for (i, pixel) in sensor.data.iter_mut().enumerate() { - let (x, y) = ((i % w) as f32, (i / w) as f32); - let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos(); - *pixel = [value; 3]; - } - let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] }; - let mut reference = Rgb32f::new(w, h); - for (i, pixel) in reference.data.iter_mut().enumerate() { - let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); - *pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7)); - } - let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone"); - assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}"); - assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}"); - assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}"); - assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged"); - reference.data.fill([0.2; 3]); - assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing"); - for (i, pixel) in reference.data.iter_mut().enumerate() { - let (x, y) = ((i % w) as f32, (i / w) as f32); - *pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3]; - } - assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing"); - } - - /// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus - /// download or an unfinished codec cannot make this test claim a successful RAW calibration. - #[test] - fn corpus_cr3_gets_a_framing_aligned_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/cr3-canon-r100-raw.cr3"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let raw = match lightcraft_raw::decode(&bytes) { - Ok(raw) => raw, - Err(lightcraft_raw::RawError::Unsupported(why)) => { - lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); - return; - } - Err(error) => panic!("CR3 corpus failed to decode: {error}"), - }; - assert_eq!(raw.format, RawFormat::Cr3); - assert_eq!(raw.info().developed_size(), (6000, 4000)); - let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform, None).0; - eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); - assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.raw && info.relative_wb); - assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0)); - assert_eq!(info.camera_tone.is_some(), look.is_some()); - } - - /// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model` - /// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the - /// photo's own fit (the fallback when a profile is rejected) would not. - fn corpus_uses_the_bundled_profile(file: &str, model: &str) { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw") - .join(file); - let bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - } - Err(e) => panic!("{}: {e}", path.display()), - }; - let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in"); - let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); - let used = crate::camera_profiles::get(model).expect("a profile for the model"); - assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(raw.metadata.model.as_deref(), Some(model)); - // as the app does (`files.rs`): the camera's own lens warp pairs the sensor with its JPEG (Nikon's in-file - // distortion for the Z 6II and Z 8), and the lens corrections leave the opcodes before the proxy is binned - let lens = lens_geometry(&raw.opcodes.list3); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform, lens.as_ref()).0.expect("fit accepted"); - let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); - for (row, want) in look.matrix.0.iter().zip(expected.0) { - for (got, want) in row.iter().zip(want) { - assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0); - } - } - assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); - } - - #[test] - fn corpus_a7cr_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR"); - } - - #[test] - fn corpus_a99m2_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2"); - } - - /// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. - #[test] - fn corpus_z6ii_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); - } - - /// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. - #[test] - fn corpus_z8_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); - } - - /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white - /// balance is relative to the as-shot look. - #[test] - fn corpus_nef_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/nef-nikon-d7500-lossless14.nef"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); - } - - /// The public ILCE-7CR samples (61 MP; skipped without the corpus): lossless compressed L and M and compressed - /// ARW2 all get a look fitted to their own JPEG, not the neutral fallback. - #[test] - fn corpus_a7cr_codings_get_a_camera_look() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - for name in ["arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw", "arw-sony-a7cr-compressed.arw"] { - let bytes = match std::fs::read(dir.join(name)) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - lightcraft_testkit::skip!(Corpus, "{name} absent"); - continue; - } - Err(e) => panic!("{name}: {e}"), - }; - let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!((img.width, img.height), (400, 267), "{name}: framed in the default crop"); - assert!(info.camera_tone.is_some(), "{name}: no camera look fitted"); - assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0, "{name}"); - } - } - - /// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3 - /// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it. - #[test] - fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio"); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); - } - - /// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is - /// distortion-corrected, and on its glass façade the fit on all pixels misses the gate - /// (held-out RMS 0.113): it used to open grey with the neutral fallback. - #[test] - fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - } - - /// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same - /// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation - /// table and its tone curve; without a look, those of the camera transform. - #[test] - fn dng_colour_renders_like_the_raws_starting_colour() { - use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; - let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37)); - assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb); - let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); - let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; - let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); - for look in [Some(&look), None] { - let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap(); - let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); - for i in 0..3 { - assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); - } - let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); - for c in samples { - let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c)); - assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}"); - } - assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone())); - let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve); - match look { - Some(look) => { - let tone = tone.expect("the look's tone curve"); - let worst = - (0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max); - // (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here) - assert!(worst < 4e-3, "{worst}"); - } - None => assert!(tone.is_none()), - } - } - } - - /// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white - /// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their - /// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8. - #[test] - fn dng_colour_declares_the_white_balance_the_source_shows() { - use crate::files::DngWhite; - use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers}; - let tags = ColorData::default(); - let as_shot = [2.5039f32, 1.0, 1.5078]; - let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot))); - let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); - look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]); - let tint = look.matrix.apply([1.0; 3]); - assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}"); - let renders_as_shot = |dng: &ColorData| { - let r = camera_transform_of(dng, as_shot_white_of(dng, None)); - assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb); - let m = look.matrix.mul(&t.matrix); - for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] { - let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()); - let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2()); - assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}"); - } - }; - // a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0) - for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] { - let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap(); - let back = as_shot_white_of(&dng, None); - assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}"); - renders_as_shot(&dng); - } - // a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source - let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera"); - let mut model = tags.clone(); - assert!(camera.fill(&mut model)); - let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap(); - let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap(); - let back = as_shot_white_of(&dng, None); - assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}"); - for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] { - let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w)); - assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}"); - } - renders_as_shot(&dng); - } - - /// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without - /// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking - /// it from the multipliers again leaves the merged DNG rendering as the tags do. - #[test] - fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { - use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; - for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { - let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); - raw.wb_multipliers = Some(multipliers); - let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); - let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); - // the reciprocal of the multipliers, up to the scale a neutral is given in - let scale = neutral[1] * multipliers[1] as f64; - for i in 0..3 { - assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); - } - let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); - merged.set_as_shot_multipliers(multipliers); - let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; - let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); - for i in 0..3 { - assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); - } - for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { - assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); - } - assert_eq!(a.baseline_exposure, b.baseline_exposure); - } - } - - /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. - fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { - CameraLook { - matrix: Mat3([[1.6, -0.4, -0.2], [-0.2, 1.4, -0.2], [0.0, -0.3, 1.3]]), - tone: CameraTone::new(xs.map(|x| [x, f(x)])).unwrap().with_chroma([1.3, 1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.3]).unwrap(), - hue_sat: Some(HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.1, 1.0]; 8], srgb_value: false }), - } - } - - /// `fixed` (from `fitted`) is `min(fitted, Sony's DRO-off curve)` at every knot but the - /// second-last, which may stay higher (at most the fitted curve) to keep the shoulder past the - /// last knot below the fitted one; nowhere brighter than `fitted`, colour unchanged. - fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) { - let dro_off = sony_dro_off_tone().unwrap(); - assert_eq!((fixed.matrix, &fixed.hue_sat, fixed.tone.chroma()), (fitted.matrix, &fitted.hue_sat, fitted.tone.chroma()), "{what}"); - for (i, (k, own)) in fixed.tone.knots().iter().zip(fitted.tone.knots()).enumerate() { - let lowered = own[1].min(dro_off.apply(own[0])); - assert_eq!(k[0], own[0], "{what}: knot {i}"); - if i == 30 { - assert!(k[1] >= lowered && k[1] <= own[1], "{what}: knot {i} {} not in {lowered}..={}", k[1], own[1]); - } else { - assert!((k[1] - lowered).abs() < 1e-6, "{what}: knot {i} {} vs {lowered}", k[1]); - } - } - assert!(at_most(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve somewhere"); - // the same check spelled out, from deep shadows to far past the last knot (the shoulder) - for i in 0..=2400 { - let x = 0.001 * 1.005f32.powi(i); - assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x)); - } - } - - /// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A - /// look fitted to such a JPEG keeps its colour (matrix, hue/saturation table, chroma curve) and - /// its curve is lowered to Sony's without DRO wherever that is darker, never raised. - #[test] - fn a_dro_look_keeps_its_colour_and_is_lowered_to_the_dro_off_tone() { - let dro_off = sony_dro_off_tone().expect("the built-in curve is valid"); - // the curve itself: black stays black, increasing, mid-grey where cameras put it (L* 50–75) - assert_eq!(dro_off.apply(0.0), 0.0); - let mut previous = 0.0; - for i in 1..200 { - let y = dro_off.apply(i as f32 * 0.005); - assert!(y > previous && y < 1.0, "{i}: {y}"); - previous = y; - } - assert!((0.18..0.48).contains(&dro_off.apply(0.18)), "{}", dro_off.apply(0.18)); - let xs: [f32; 32] = std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)); - // a JPEG with lifted shadows: the curve comes down to Sony's, colour and chroma curve stay - let lifted = dro_test_look(&xs, &|x| dro_off.apply(x).powf(0.8)); - let fixed = with_dro_off_tone(lifted.clone()); - assert_lowered(&lifted, &fixed, "lifted"); - assert!(fixed.tone.apply(0.02) < 0.85 * lifted.tone.apply(0.02)); - // a camera that maps raw values darker than Sony's curve keeps its own curve exactly - let darker = dro_test_look(&xs, &|x| 0.7 * dro_off.apply(x)); - assert_eq!(with_dro_off_tone(darker.clone()).tone, darker.tone); - // crossing curves: the lower of the two at the knots, still a valid increasing curve - let crossing = dro_test_look(&xs, &|x| (dro_off.apply(x) * (0.6 + x)).min(0.95)); - assert_lowered(&crossing, &with_dro_off_tone(crossing.clone()), "crossing"); - } - - /// Past its last knot a camera curve continues as a shoulder whose rate comes from its last two - /// knots. Lowering the second-last knot more than the last steepened that shoulder: the curve - /// fitted to the CC0 NEX-5T sample (raw.pixls.us, DRO Auto) went from 0.740 to 0.843 at scene - /// luminance 0.63, brighter than before. The lowered curve must stay at or below the fitted one - /// through the shoulder, on that curve and on many others. - #[test] - fn the_lowered_curve_stays_below_the_fitted_one_past_its_last_knot() { - let nex5t: [[f32; 2]; 32] = [ - [0.014948029, 0.017424058], - [0.018395446, 0.023981506], - [0.021539066, 0.030581286], - [0.024555191, 0.0374646], - [0.027602654, 0.044408206], - [0.030193308, 0.049894042], - [0.03261828, 0.055907387], - [0.034544908, 0.06156683], - [0.03669959, 0.06814718], - [0.039161213, 0.075263545], - [0.04173663, 0.0830011], - [0.044166446, 0.088166036], - [0.04682434, 0.095398724], - [0.049544845, 0.1027153], - [0.05235826, 0.11173598], - [0.05497513, 0.119017586], - [0.057503678, 0.12616614], - [0.059836045, 0.13521175], - [0.062677935, 0.14601946], - [0.06599234, 0.15573128], - [0.069809504, 0.16883339], - [0.0755404, 0.1854325], - [0.08250754, 0.20684941], - [0.089204684, 0.23466235], - [0.09650368, 0.25805998], - [0.102161564, 0.27849936], - [0.107812546, 0.29706743], - [0.11410169, 0.31379074], - [0.12000221, 0.33262977], - [0.12776275, 0.35407156], - [0.1387929, 0.38362882], - [0.19492775, 0.43935108], - ]; - let fitted = CameraLook { tone: CameraTone::new(nex5t).unwrap(), ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; - let fixed = with_dro_off_tone(fitted.clone()); - assert_lowered(&fitted, &fixed, "NEX-5T"); - assert!((fitted.tone.apply(0.63) - 0.7405).abs() < 1e-3 && fixed.tone.apply(0.63) <= fitted.tone.apply(0.63)); - assert_ne!(fixed.tone, fitted.tone, "the lifted mid-tones still come down"); - // curves that end at all kinds of scene luminances, slopes and heights relative to Sony's - let dro_off = sony_dro_off_tone().unwrap(); - let mut seed = 0x9e37_79b9_u32; - let mut next = move || { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - f32::from((seed >> 16) as u16) / 65536.0 - }; - for case in 0..300 { - let (start, span) = (0.002 + 0.03 * next(), 1.05 + 0.15 * next()); - let xs: [f32; 32] = std::array::from_fn(|i| start * span.powi(i as i32)); - let (lift, gain, top) = (0.5 + 0.6 * next(), 0.6 + 0.8 * next(), next()); - // brighter or darker than Sony's, with a last knot sometimes lowered less than the one before - let f = |x: f32| (gain * dro_off.apply(x).powf(lift) + top * 0.2 * (x / xs[31]).powi(8)).min(0.995); - let mut ys = xs.map(f); - for i in 1..32 { - ys[i] = ys[i].max(ys[i - 1]); - } - let Some(tone) = CameraTone::new(std::array::from_fn(|i| [xs[i], ys[i]])) else { continue }; - let fitted = CameraLook { tone, ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; - assert_lowered(&fitted, &with_dro_off_tone(fitted.clone()), &format!("case {case}")); - } - } - - /// Issue #244 (skipped without the corpus): the public ILCE-7RM2 sample (Standard creative - /// style) was shot with DRO Auto, so its curve comes down to Sony's without DRO in the shadows the - /// camera lifted; the ILCE-7M4 sample (DRO Auto, Vivid) has deeper shadows than that curve and - /// keeps them, only its brighter highlights come down. Both keep the colour fitted to their - /// JPEGs. The ILCE-7M3 sample (DRO off) keeps its own fit. - #[test] - fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let samples = [ - ("arw-sony-a7rm2-12bit-uncompressed.arw", true, [0.01, 0.05, 0.1]), - ("arw-sony-a7m4-14bit.arw", true, [0.3, 0.4, 0.5]), - ("arw-sony-a7m3-compressed.arw", false, [0.0; 3]), - ]; - for (name, dro, lowered) in samples { - let path = dir.join(name); - let bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - continue; - } - Err(e) => panic!("{}: {e}", path.display()), - }; - assert_eq!(lightcraft_raw::embedded_preview_dynamic_range_optimized(&bytes), Some(dro), "{name}"); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); - // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap(); - let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); - let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); - if dro { - assert_lowered(&fitted, &look, name); - for x in lowered { - assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x)); - } - } else { - assert_eq!((look.matrix, &look.hue_sat, look.tone), (fitted.matrix, &fitted.hue_sat, fitted.tone), "{name}"); - } - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!(info.camera_tone, Some(look.tone), "{name}"); - } - } - - /// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a - /// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree - /// everywhere away from the edges, and the look must still be found there. - #[test] - fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let (w, h, block, shift) = (96, 64, 8, 2); - // Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour. - let scene = |x: usize, y: usize| -> [f32; 3] { - let (bx, by) = (x / block, y / block); - let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 }; - [ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)] - }; - let camera = |p: [f32; 3]| { - let q = known.apply_f32(p); - let y = luminance_2020(q); - q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) - }; - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - for y in 0..h { - for x in 0..w { - sensor.data[y * w + x] = scene(x, y); - // The JPEG shows the scene `shift` pixels to the right of where the raw has it. - reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y)); - } - } - let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges"); - // The look itself is right: on the aligned scene it reproduces the camera. - let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); - let (mut error, mut n) = (0.0, 0); - for y in 0..h { - for x in 0..w { - let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone); - let t = camera(scene(x, y)); - error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::(); - n += 3; - } - } - assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt()); - // Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well. - reference.data.fill([0.2; 3]); - assert!(fit_pairs(&sensor, &reference).is_none()); - } - - /// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes. - /// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away - /// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0 - /// corpus files. - #[test] - fn corpus_raf_colour_and_white_balance() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - // (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those - // matrices; the X-T2 and X-T20 have none and stay relative) - for (name, accepted, kelvin) in - [("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)] - { - let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; - let header = crate::files::probe_bytes(name, &bytes).unwrap(); - let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); - let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); - assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); - assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); - assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}"); - assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}"); - if kelvin { - assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp); - } else { - assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); - } - } - } - - /// Sony ARWs without measured matrices read Kelvin from their white-balance presets - /// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and - /// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its - /// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files. - #[test] - fn corpus_arw_white_balance_in_kelvin_through_the_presets() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] { - let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); - continue; - }; - let header = crate::files::probe_bytes(name, &bytes).unwrap(); - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); - assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); - assert!( - (info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, - "{name}: {} {}", - info.as_shot_temp, - info.as_shot_tint - ); - let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); - } - } - - #[test] - fn rejects_monochrome_invalid_and_unrelated_previews() { - let mut sensor = Rgb32f::new(32, 32); - let mut reference = sensor.clone(); - for (i, p) in sensor.data.iter_mut().enumerate() { - *p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1]; - } - reference.data.fill([0.2; 3]); - assert!(fit_pairs(&sensor, &reference).is_none()); - reference.data.fill([f32::NAN; 3]); - assert!(fit_pairs(&sensor, &reference).is_none()); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - *src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01]; - *dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; - } - assert!(fit_pairs(&sensor, &reference).is_none()); - sensor.data.fill([0.1, 0.15, 0.12]); - assert!(fit_pairs(&sensor, &reference).is_none()); - reference.data.truncate(8); - assert!(fit_pairs(&sensor, &reference).is_none()); - } - - /// A busy scene whose JPEG is the camera curve of the scene two proxy pixels to the right (a - /// lens-corrected preview): the fitted curve follows the camera's and has no flat steps. - #[test] - fn tone_fit_has_no_steps_on_a_misregistered_jpeg() { - let camera = |x: f64| 0.6 * x.powf(0.55) / (1.0 + 0.2 * x); - let (w, h) = (96usize, 64usize); - let mut seed = 0x2545_f491_u32; - let mut scene = Vec::with_capacity(w * h); - for _ in 0..w * h { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - scene.push(0.01 * (5.0 * f64::from(seed >> 8) / f64::from(1u32 << 24)).exp()); - } - let pairs: Vec<(f64, f64)> = - (0..h).flat_map(|y| (0..w - 2).map(move |x| (y * w + x, y * w + x + 2))).map(|(i, j)| (scene[i], camera(scene[j]))).collect(); - let curve = fit_tone(pairs).unwrap(); - let mut sorted = scene.clone(); - sorted.sort_by(f64::total_cmp); - let (lo, hi) = (sorted[sorted.len() / 50], sorted[sorted.len() * 49 / 50]); - let mut x = lo; - while x < hi { - let (a, b) = (curve.apply(x as f32), curve.apply((x * 1.02) as f32)); - assert!(b > a, "flat step at {x}: {a} -> {b}"); - assert!((f64::from(a) / camera(x) - 1.0).abs() < 0.05, "at {x}: {a} vs {}", camera(x)); - x *= 1.02; - } - } - - /// A synthetic photo whose JPEG is the camera look of the scene scaled per pixel by noise of - /// `spread` (a pseudo-random, registration-free stand-in for local tone mapping): the per-bin - /// median recovers the curve, while the JPEG's broadened distribution stretches the quantile fit. - fn noisy_look(spread: f32) -> (Rgb32f, Rgb32f) { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - let mut seed = 0x9e37_79b9_u32; - let mut next = move || { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - f32::from((seed >> 16) as u16) / 32768.0 - 1.0 - }; - for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { - let ev = 0.05 + (next() + 1.0) * 0.2; - *src = [ev * (0.8 + (next() + 1.0) * 0.1), ev, ev * (0.8 + (next() + 1.0) * 0.1)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - let scale = (1.0 - (-2.5 * y).exp()) / y * (1.0 + spread * next()); - *dst = p.map(|v| v * scale); - } - (sensor, reference) - } - - /// A camera rendering with constant colourfulness (no highlight bleaching) of a colourful scene - /// under a bright neutral sky, whose sensor pixels are clipped: one channel held at the clip - /// level turns the white sky magenta in the raw while the camera JPEG keeps it white. Returns - /// the sensor proxy, the JPEG proxy and the clip mask. - fn clipped_sky_scene() -> (Rgb32f, Rgb32f, Vec) { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - let mut clipped = vec![false; w * h]; - let mut seed = 0x2545_f491_u32; - let mut next = move || { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - f32::from((seed >> 16) as u16) / 65536.0 - }; - let camera = |p: [f32; 3]| { - let y = luminance_2020(p); - let o = 1.0 - (-2.5 * y).exp(); - p.map(|v| (v * o / y).clamp(0.0, 0.97)) - }; - // the sky: a quarter of the frame, white in the scene and in the JPEG - let neutral = known.inverse().unwrap().apply_f32([0.32; 3]); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let scene = if i < w * h / 4 { - clipped[i] = true; - let sky = neutral.map(|v| v * (0.8 + 0.4 * next())); - *dst = camera(known.apply_f32(sky)); - // the green sample clips, red and blue (after white balance) go on rising - *src = [sky[0] * 1.35, sky[1] * 0.85, sky[2] * 1.4]; - continue; - } else { - // colourful foliage, leaves, bark and bright flowers from dark to light - let ev = 0.02 + next() * 0.4; - [ev * (0.6 + next() * 0.8), ev, ev * (0.5 + next() * 0.8)] - }; - *src = scene; - *dst = camera(known.apply_f32(scene)); - } - (sensor, reference, clipped) - } - - /// Mean colourfulness (distance from neutral of luminance-normalised RGB) of the unclipped - /// pixels rendered with `look`, over that of the camera JPEG, per display-luminance band. - fn colourfulness_ratio(look: &CameraLook, sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], band: std::ops::Range) -> f64 { - let chroma = |p: [f64; 3]| { - let y = luma(p); - p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt() - }; - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let (mut rendered, mut camera) = (0.0, 0.0); - for ((x, y), c) in sensor.data.iter().zip(&reference.data).zip(clipped) { - let y = y.map(f64::from); - if *c || !band.contains(&luma(y)) { - continue; - } - rendered += chroma(displayed(look.matrix.apply(x.map(f64::from)), &tone)); - camera += chroma(y); - } - rendered / camera - } - - /// Clipped sensor pixels are left out of the look's training pairs: a clipped white sky no longer - /// dulls the colour matrix or makes the chroma curve remove colour at the sky's brightness. - #[test] - fn clipped_sky_does_not_dull_the_look() { - let (sensor, reference, clipped) = clipped_sky_scene(); - let old = fit_pairs(&sensor, &reference).expect("the earlier fit accepts the look"); - let new = fit_look(&sensor, &reference, &clipped, None).expect("look without the clipped pixels"); - let (mid, bright) = (0.1..0.35, 0.35..0.75); - let old_ratio = ( - colourfulness_ratio(&old, &sensor, &reference, &clipped, mid.clone()), - colourfulness_ratio(&old, &sensor, &reference, &clipped, bright.clone()), - ); - let new_ratio = - (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright)); - // with the clipped sky in the pairs, the bright colours lose much of their colour - assert!(old_ratio.1 < 0.8, "earlier fit: colourfulness vs camera {old_ratio:?}, chroma {:?}", old.tone.chroma()); - // without it, the render keeps the camera's colourfulness at every brightness - for r in [new_ratio.0, new_ratio.1] { - assert!((0.9..1.1).contains(&r), "colourfulness vs camera {new_ratio:?}, chroma {:?}", new.tone.chroma()); - } - assert!(new.tone.chroma().iter().all(|k| *k > 0.85), "no colour cut: {:?}", new.tone.chroma()); - } - - /// When the accepted attempt misses the gates without the clipped pixels (here: too few pixels - /// left), the look fitted on all pixels is kept unchanged. - #[test] - fn clipped_fit_falls_back_to_all_pixels() { - let (sensor, reference, _) = clipped_sky_scene(); - let all = fit_pairs(&sensor, &reference).unwrap(); - // nearly everything clipped: too few pairs remain, the earlier look is used unchanged - let mut clipped = vec![true; sensor.data.len()]; - clipped[..100].iter_mut().for_each(|c| *c = false); - let fallback = fit_look(&sensor, &reference, &clipped, None).expect("the fallback keeps the look"); - assert_eq!((fallback.matrix.0, fallback.tone), (all.matrix.0, all.tone)); - // nothing clipped: the same look as before, exactly - let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap(); - assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); - // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) - let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap(); - assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); - assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); - } - - /// Quantile matching is tried first; the conditional-median fit is the fallback and is used - /// only where the quantile look misses the acceptance gates, so no photo loses its look. - #[test] - fn tone_fit_order_quantile_first_conditional_median_as_fallback() { - // Clean data: both fits pass, the default order uses the quantile curve (increasing). - let (sensor, reference) = noisy_look(0.0); - let default = fit_pairs(&sensor, &reference).expect("clean look"); - let quantile = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).expect("quantile passes"); - assert_eq!(default.tone, quantile.tone); - // Noisy data: the quantile fit misses the gates, the conditional median still passes. - let (sensor, reference) = noisy_look(0.33); - assert!(fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).is_none(), "quantile alone misses the gates"); - let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).expect("conditional median passes"); - let default = fit_pairs(&sensor, &reference).expect("the fallback keeps the look"); - assert_eq!(default.tone, conditional.tone); - // The order matters only through the first pass; with the fits swapped the conditional one wins even on clean data. - let (sensor, reference) = noisy_look(0.0); - let swapped = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian, ToneFit::Quantile]).unwrap(); - let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap(); - assert_eq!(swapped.tone, conditional.tone); - } - - /// The camera look of `scene` (the known matrix of these tests, then a bright camera curve). - fn bright_camera(p: [f32; 3]) -> [f32; 3] { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let q = known.apply_f32(p); - let y = luminance_2020(q); - if y <= 0.0 { [0.0; 3] } else { q.map(|v| v * (1.0 - (-4.0 * y).exp()) / y) } - } - - /// Mean squared error (all channels, display-linear) of `render` against `target` over `scene`. - fn mean_error(scene: &Rgb32f, target: impl Fn([f32; 3]) -> [f32; 3], render: impl Fn([f64; 3]) -> [f64; 3]) -> f64 { - let error: f64 = scene - .data - .iter() - .map(|x| { - let (p, t) = (render(x.map(f64::from)), target(*x)); - (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::() - }) - .sum(); - error / (3 * scene.data.len()) as f64 - } - - /// A colourful scene with fine detail everywhere (foliage) whose camera JPEG is lens-corrected: - /// barrel distortion moves the picture by up to about 4 proxy pixels toward the corners, so the - /// detail no longer pairs with itself and no global look passes the gates, on all pixels or - /// away from edges. The partial look still follows the camera's brightness, colourfulness and - /// hues: on the registered scene it is far closer to the camera than the neutral fallback, - /// which renders the scene dark and grey. - #[test] - fn partial_look_when_a_lens_corrected_jpeg_misses_the_gates() { - let (w, h) = (96usize, 64usize); - let scene = |u: f32, v: f32| -> [f32; 3] { - let base = 0.1 * (1.0 + 0.6 * (0.05 * u + 0.04 * v).sin()); - let detail = 1.0 + 0.8 * (1.7 * u).sin() * (1.3 * v).sin(); - let y = base * detail; - [y * (0.6 + 0.5 * (0.07 * u).sin().abs()), y, y * (0.5 + 0.6 * (0.09 * v + 0.03 * u).cos().abs())] - }; - let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - for y in 0..h { - for x in 0..w { - let (dx, dy) = (x as f32 - cx, y as f32 - cy); - let k = 1.0 + 0.08 * (dx * dx + dy * dy) / (cx * cx); - sensor.data[y * w + x] = scene(x as f32, y as f32); - reference.data[y * w + x] = bright_camera(scene(cx + dx * k, cy + dy * k)); - } - } - assert!(fit_pairs(&sensor, &reference).is_none(), "the full look misses the gates"); - let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look instead of the neutral fallback"); - assert!(look.hue_sat.is_none()); - assert_ne!(look.matrix, Mat3::IDENTITY, "a colourful scene gets the damped matrix"); - // on the registered scene, against what the camera renders - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let partial = mean_error(&sensor, bright_camera, |x| displayed(look.matrix.apply(x), &tone)); - let fallback = mean_error(&sensor, bright_camera, |x| displayed(x, &neutral)); - assert!(partial < fallback * 0.25, "partial {partial:.5}, neutral fallback {fallback:.5}"); - assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8)); - } - - fn near_black_scene() -> (Rgb32f, Rgb32f) { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 }; - let rgb = [1.15, 1.0, 0.75]; - *input = rgb.map(|v| v * y / luminance_2020(rgb)); - *output = input.map(|v| v * 0.08); - } - (sensor, reference) - } - - #[test] - fn a_near_black_scene_gets_tone_without_learning_colour() { - let (sensor, reference) = near_black_scene(); - assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look"); - let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default()); - let (_, _, look) = starting_colour_with( - &mut raw, - |_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false), - |_, _| None, - |_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]), - ); - let look = look.expect("tone from the dark pixels"); - assert_eq!(look.matrix, Mat3::IDENTITY); - assert!(look.hue_sat.is_none()); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error = |tone: &ToneMap| { - sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)) - .sum::() - }; - assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001); - assert_eq!(look.tone.apply(0.0), 0.0); - let mut previous = 0.0; - for i in 0..10_000 { - let y = look.tone.apply(i as f32 / 10_000.0); - assert!(y.is_finite() && y >= previous && y <= 1.0); - previous = y; - } - // Models with spectral matrices keep them before the dark tone is fitted. - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, false), - |_, _| None, - |raw, t| { - assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color)); - fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]) - }, - ); - assert!(!t.matrix_is_fallback); - assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY); - } - - #[test] - fn dark_tone_holdout_never_enters_training() { - let (sensor, mut reference) = near_black_scene(); - let none = vec![false; sensor.data.len()]; - let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone"); - // Every pixel clears the wider signal floors. Changing only its held-out third must - // change the measured error, but cannot change the fitted curve. - for p in reference.data.iter_mut().step_by(3) { - *p = p.map(|v| v * 1.03); - } - let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback"); - assert_eq!(changed.tone, original.tone); - } - - #[test] - fn dark_tone_requires_signal_correlation_and_unclipped_highlights() { - let (sensor, reference) = near_black_scene(); - let none = vec![false; sensor.data.len()]; - let mut unrelated = reference.clone(); - for (i, p) in unrelated.data.iter_mut().enumerate() { - *p = reference.data[(i * 3571) % reference.data.len()]; - } - assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG"); - let mut flat = reference.clone(); - flat.data.fill([0.002; 3]); - assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone"); - let mut sparse = reference.clone(); - sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]); - assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs"); - let mut bright = reference.clone(); - bright.data[0] = [0.95; 3]; - assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight"); - let mut clipped = none; - clipped[0] = true; - let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel"); - let mut changed = sensor.clone(); - changed.data[0] = [0.18; 3]; - let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG"); - assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve"); - let mut highlight = reference.clone(); - highlight.data[0] = [0.8; 3]; - assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG"); - assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions"); - assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask"); - } - - /// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is - /// learnt and the full look is never tried) whose camera renders it two stops brighter than the - /// neutral fallback: the partial look is the tone curve (and chroma curve) alone. - #[test] - fn partial_look_brightens_a_dull_scene_without_learning_colour() { - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.01 + (i % 37) as f32 * 0.004; - let tint = if i % 50 == 0 { 1.3 } else { 1.0 + (i % 5) as f32 * 0.004 }; - *p = [ev * tint, ev, ev * (2.0 - tint)]; - } - let camera = |p: [f32; 3]| { - let y = luminance_2020(p); - p.map(|v| v * (1.0 - (-12.0 * y).exp()) / y) - }; - let mut reference = sensor.clone(); - reference.map_in_place(camera); - assert!(fit_pairs(&sensor, &reference).is_none(), "too little colour for the full look"); - let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look"); - assert_eq!(look.matrix, Mat3::IDENTITY, "no colour matrix from a colourless scene"); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let lightness = - |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::() / sensor.data.len() as f64; - let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / reference.data.len() as f64; - assert!((lightness(&tone) / target - 1.0).abs() < 0.05, "{} vs {target}", lightness(&tone)); - assert!(lightness(&neutral) < target * 0.6, "the neutral fallback is far darker"); - } - - /// When the neutral fallback already matches the camera JPEG, or the JPEG is unrelated or - /// monochrome, the partial look isn't clearly better and the photo keeps the neutral fallback. - #[test] - fn partial_look_only_when_clearly_better_than_the_neutral_fallback() { - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.02 + (i % 41) as f32 * 0.012; - *p = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.045)]; - } - let none = vec![false; w * h]; - // the camera renders exactly as the neutral fallback: nothing to gain - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let mut reference = sensor.clone(); - reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32)); - let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback"); - // an unrelated picture - for (i, p) in reference.data.iter_mut().enumerate() { - *p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; - } - assert!(fit_look(&sensor, &reference, &none, None).is_none(), "unrelated JPEG"); - // a black-and-white JPEG of the scene - reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]); - assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG"); - } - - /// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's - /// luminance, grey in every pixel. - fn black_and_white_scene() -> (Rgb32f, Rgb32f) { - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.01 + (i % 53) as f32 * 0.009; - *p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)]; - } - let mut reference = sensor.clone(); - reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]); - (sensor, reference) - } - - #[test] - fn black_and_white_jpegs_are_recognised() { - let (sensor, mut reference) = black_and_white_scene(); - assert!(monochrome(&reference)); - assert!(!monochrome(&sensor), "a colour JPEG"); - // a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour - let n = reference.data.len(); - reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05); - assert!(monochrome(&reference)); - reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05); - assert!(!monochrome(&reference)); - // a sepia-toned monochrome - let (_, mut toned) = black_and_white_scene(); - toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]); - assert!(!monochrome(&toned)); - assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell"); - // the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts - let (_, mut faint) = black_and_white_scene(); - faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]); - assert!(monochrome(&faint)); - faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]); - assert!(!monochrome(&faint)); - // pixels that aren't numbers can't vouch for grey - let (_, mut broken) = black_and_white_scene(); - broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]); - assert!(!monochrome(&broken)); - } - - /// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's - /// brightness like the camera, with the colour left as it was and the chroma curve neutral. - #[test] - fn a_black_and_white_jpeg_gives_its_tone_curve() { - let (sensor, reference) = black_and_white_scene(); - let none = vec![false; sensor.data.len()]; - assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit"); - let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve"); - assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was"); - assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma()); - let n = sensor.data.len() as f64; - let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::() / n; - let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / n; - let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)); - assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}"); - assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker"); - // with a camera profile's colour, the look carries it - let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]); - let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour"); - assert_eq!(look.matrix, matrix); - } - - /// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries - /// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's. - #[test] - fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() { - // saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped) - let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]]; - let mut sensor = Rgb32f::new(96, 64); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.02 + (i % 47) as f32 * 0.008; - *p = hues[i % 6].map(|c| c * ev); - } - // a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let mut reference = sensor.clone(); - reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]); - let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); - assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks"); - let own = fit_tone_only(&sensor, &reference, None); - assert!(own.is_some(), "the photo's own colour passes"); - // and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices) - assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); - let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, true), - |_, t| { - assert!(!t.matrix_is_fallback, "on the spectral matrices"); - own.clone() - }, - |_, _| None, - ); - assert!(look.is_some() && !t.matrix_is_fallback); - } - - /// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG, - /// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the - /// same file with a tinted JPEG gets nothing from this step. - #[test] - fn monochrome_look_reads_the_files_black_and_white_jpeg() { - use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect}; - let (w, h) = (192usize, 128usize); - let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); - let raw = RawImage { - format: RawFormat::Arw, - width: w, - height: h, - cpp: 3, - data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()), - cfa: None, - bits: 16, - black: BlackLevel::uniform(0.0), - white: vec![1.0], - active_area: Rect::new(0, 0, w, h), - crop: Rect::new(0, 0, w, h), - orientation: Orientation::from_exif(1), - color: ColorData::default(), - wb_multipliers: Some([1.0; 3]), - linearized: true, - pixel_aspect: 1.0, - opcodes: OpcodeLists::default(), - metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, - lens_metadata: Default::default(), - }; - assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); - // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG - let file = |tint: [f32; 3]| { - let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; - let pixels: Vec = (0..w * h) - .flat_map(|i| { - let v = 1.0 - (-3.0 * level(i % w, i / w)).exp(); - tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8) - }) - .collect(); - let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); - let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); - // II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it - let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec(); - for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] { - tiff.extend(tag.to_le_bytes()); - tiff.extend(4u16.to_le_bytes()); - tiff.extend(1u32.to_le_bytes()); - tiff.extend(value.to_le_bytes()); - } - tiff.extend(0u32.to_le_bytes()); - assert_eq!(tiff.len(), 38); - tiff.extend(jpeg); - tiff - }; - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); - assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); - let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let mid = curve.apply(0.25); - assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); - assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); - } - - /// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve - /// already renders, gives no look. - #[test] - fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() { - let (sensor, mut reference) = black_and_white_scene(); - let neutral = ToneMap::new(0.0, 0.0, 0.0); - reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]); - assert!(monochrome(&reference)); - assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve"); - for (i, p) in reference.data.iter_mut().enumerate() { - *p = [0.05 + (i % 29) as f32 * 0.02; 3]; - } - assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture"); - // a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties - // ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey) - let mut sorted = sensor.clone(); - sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b))); - reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); - assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); - } - - /// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted. - #[test] - fn corpus_arw6_full_frame_gets_a_camera_look() { - const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) }; - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/arw6-sony-ilce7rm6-ff-land-c.arw"); - let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); - return; - }; - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - } - - /// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. - fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { - let scene = |x: f32, y: f32| -> [f32; 3] { - let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; - [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] - }; - let camera = |p: [f32; 3]| { - let y = luminance_2020(p); - p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) - }; - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - for y in 0..h { - for x in 0..w { - sensor.data[y * w + x] = scene(x as f32, y as f32); - reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); - } - } - (sensor, reference) - } - - /// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a - /// flat and a differently sized preview. - #[test] - fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { - let (sensor, reference) = scene_pair(96, 64, 0); - assert!(structure_agrees(&sensor, &reference)); - assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); - assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); - let mut stripes = reference.clone(); - for (i, p) in stripes.data.iter_mut().enumerate() { - *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; - } - assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); - let mut flat = reference.clone(); - flat.data.fill([0.2; 3]); - assert!(!structure_agrees(&sensor, &flat)); - assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); - } - - /// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that - /// renders mid-grey brighter than the default tone map is followed. - #[test] - fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { - let (mut sensor, mut reference) = scene_pair(96, 64, 0); - for image in [&mut sensor, &mut reference] { - for p in image.data.iter_mut().step_by(5) { - *p = [p[0] * 1.6, p[1], p[2] * 0.5]; - } - } - let colour = Some((Mat3::IDENTITY, None)); - let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - let look = - fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check) - .expect("tone fitted"); - assert_eq!(look.matrix, Mat3::IDENTITY); - assert!(look.hue_sat.is_none()); - let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - let target = 1.0 - (-2.5f32 * 0.18).exp(); - assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); - assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); - assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); - } - - /// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the - /// camera preview's, in one direction only: clipping what the camera clips is not refused. - #[test] - fn dng_highlight_gate_compares_near_white_shares() { - let mut sensor = Rgb32f::new(32, 32); - for (i, p) in sensor.data.iter_mut().enumerate() { - *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; - } - let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); - let mut white = Rgb32f::new(32, 32); - white.data.iter_mut().for_each(|p| *p = [1.0; 3]); - let mut black = Rgb32f::new(32, 32); - black.data.iter_mut().for_each(|p| *p = [0.1; 3]); - assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); - assert!(!clips_more(&sensor, &black, &dark, &bright)); - assert!(!clips_more(&sensor, &black, &bright, &bright)); - assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); - } - - /// A dark scene's shadows below the usual training floor still reach the tone-only fit. - #[test] - fn dng_tone_fit_trains_on_dark_pixels() { - let mut sensor = Rgb32f::new(64, 64); - let mut reference = Rgb32f::new(64, 64); - for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { - let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; - *s = [v, v * 1.1, v * 0.9]; - *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; - } - let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); - let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); - assert!(dark > usual, "{dark} vs {usual}"); - } - - /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. - #[test] - fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { - use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; - let lens = EmbeddedLens { - vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), - ..Default::default() - }; - let mut proxy = Rgb32f::new(96, 64); - proxy.data.fill([0.2; 3]); - correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); - let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); - assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); - let mut plain = Rgb32f::new(96, 64); - plain.data.fill([0.2; 3]); - correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); - assert!(plain.data.iter().all(|p| *p == [0.2; 3])); - } - - /// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's - /// full-resolution corrections applied once rather than silently losing the tone fit. - #[test] - fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() { - use lightcraft_raw::Opcode; - let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]); - raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; - assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none()); - let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable"); - assert_eq!((proxy.width, proxy.height), (32, 24)); - let center = proxy.data[12 * 32 + 16][1]; - let corner = proxy.data[0][1]; - assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}"); - let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); - assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development"); - let small = sensor_proxy(&raw, 2, 16).unwrap(); - assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce"); - } - - /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the - /// identity, with or without a look table; and no other raw format. - #[test] - fn dng_tone_gates_follow_the_files_own_tags() { - use lightcraft_raw::profile::{HsvTable, ToneCurve}; - let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); - let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; - let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { - let raw = raw_of(format, "Make", "Model", color); - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - dng_tone_eligible(&raw, &t) - }; - assert!(eligible(RawFormat::Dng, color.clone())); - assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); - assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); - let mut identity = color.clone(); - identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); - assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); - let mut curved = color.clone(); - curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); - assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); - let mut looked = color; - looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); - assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone"); - } - - /// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not. - #[test] - fn dng_tone_fit_takes_lightroom_sized_previews() { - let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0); - assert!(1024.0 >= floor(7008.0)); - assert!(256.0 < floor(7008.0)); - assert!(1024.0 >= floor(9504.0), "a 61 MP raw too"); - } - - /// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped - /// to the JPEG through a steep camera curve (a hazy scene the camera stretches). - fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> { - (0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect() - } - - /// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that - /// of the darkest 1/32 (their last and first knots). - fn knot_range(mut scene: Vec) -> f64 { - scene.sort_by(f64::total_cmp); - let n = scene.len(); - scene[31 * n / 32 + n / 64] / scene[n / 64] - } - - /// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets - /// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit. - #[test] - fn tone_fits_accept_low_contrast_scenes() { - for fit in [fit_tone, fit_tone_conditional] { - let pairs = low_contrast_pairs(1.47); - let range = knot_range(pairs.iter().map(|p| p.0).collect()); - assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}"); - let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631"); - assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1)); - assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame"); - } - } - - /// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera - /// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback. - #[test] - fn a_low_contrast_scene_gets_a_camera_look() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y); - } - let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback"); - let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect()); - assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error: f64 = sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| { - let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 - }) - .sum::() - / sensor.data.len() as f64; - assert!(error.sqrt() < 0.02, "{}", error.sqrt()); - } - - /// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the - /// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out - /// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky - /// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries' - /// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright - /// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range). - #[test] - fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - for (case, sky_value, is_sky) in [ - ("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool), - ("clipped sky", 1.0, &|i: usize| i < 5600), - ("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)), - ("sky over the pairs' range", 1.6, &|i: usize| i < 5600), - ] { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - let mut clipped = vec![false; sensor.data.len()]; - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - if is_sky(i) { - // bright, nearly white in the camera JPEG - *src = [sky_value; 3]; - *dst = [0.93; 3]; - clipped[i] = case == "clipped sky"; - continue; - } - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - // nearly flat midtones: the fitted curve's extrapolation can't reach the sky - *dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y); - } - let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue }; - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let correction = look.hue_sat.as_ref().and_then(HueSat::new); - let p = look.matrix.apply([f64::from(sky_value); 3]); - let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); - let sky = luma(displayed(p, &tone)); - assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}"); - } - } - - /// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to - /// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the - /// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the - /// sky at 0.61 where the camera JPEG has 0.93. - #[test] - fn a_narrow_curve_is_checked_as_lowered_for_dro() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - if i < 5600 { - *src = [0.9; 3]; - *dst = [0.93; 3]; - continue; - } - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y); - } - let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0))); - if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) { - let look = with_dro_off_tone(look); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let correction = look.hue_sat.as_ref().and_then(HueSat::new); - let p = look.matrix.apply([0.9; 3]); - let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); - let sky = luma(displayed(p, &tone)); - assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}"); - } - } - - /// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning - /// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted. - #[test] - fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - *dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3]; - } - let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect()); - assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); - let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve"); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2)); - let rms = (error.sum::() / sensor.data.len() as f64).sqrt(); - assert!(rms < 0.02, "{rms}"); - } - - /// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is - /// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit. - #[test] - fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - if i < 5600 { - *src = [0.9; 3]; - *dst = [0.93; 3]; - continue; - } - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - *dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3]; - } - if let Some(look) = fit_tone_only(&sensor, &reference, None) { - let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0))); - assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}"); - } - } -} +mod tests; diff --git a/crates/engine/src/camera_preview/colour.rs b/crates/engine/src/camera_preview/colour.rs new file mode 100644 index 000000000..a33a5c7a5 --- /dev/null +++ b/crates/engine/src/camera_preview/colour.rs @@ -0,0 +1,232 @@ +use super::look_fit::{fit_dng_tone, fit_preview}; +use super::mono::{fit_dark_tone, monochrome_look}; +use super::proxy::{LensGeometry, proxies}; +use super::*; + +/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates +/// below were set at this size). +pub(super) const PROXY: usize = 96; + +/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples. +pub(crate) const PROFILE_PROXY: usize = 192; + +/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix: +/// their starting look is fitted to the file's own JPEG, and white balance is relative to the +/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the +/// same formats by file extension. +pub(crate) fn file_local_look(format: RawFormat) -> bool { + matches!( + format, + RawFormat::Arw + | RawFormat::Nef + | RawFormat::Nrw + | RawFormat::Rw2 + | RawFormat::Raf + | RawFormat::Cr3 + | RawFormat::Cr2 + | RawFormat::Pef + | RawFormat::Srw + | RawFormat::Orf + | RawFormat::Mrw + | RawFormat::Erf + ) +} + +/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); a look fitted to the +/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to +/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); +/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is +/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and +/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's +/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the +/// as-shot white, the camera transform at it and the fitted look. +/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from +/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. +/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit. +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Xy, CameraTransform, Option) { + let (xy, t, look) = starting_colour_with( + raw, + |raw, t| fit_preview(raw, bytes, t, lens), + |raw, t| monochrome_look(raw, bytes, t, lens), + |raw, t| { + if !file_local_look(raw.format) { + return None; + } + let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?; + fit_dark_tone(&sensor, &reference, &clipped) + }, + ); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, embedded); + return (xy, t, look); + } + (xy, t, look) +} + +/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). +fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { + curve.is_none_or(|c| { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (c.eval(x) - x).abs() < 0.005 + }) + }) +} + +/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no +/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an +/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on +/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables +/// to its proxy, so it fits only the tone around them. +pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { + let p = &raw.color.profile; + raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none() +} + +/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white +/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them). +pub(super) fn starting_colour_with( + raw: &mut RawImage, + fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool), + monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option, + dark: impl FnOnce(&RawImage, &CameraTransform) -> Option, +) -> (Xy, CameraTransform, Option) { + let start = |raw: &RawImage| { + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + (xy, lightcraft_raw::color::camera_transform(raw, xy)) + }; + let (xy, t) = start(raw); + if !t.matrix_is_fallback { + return (xy, t, None); + } + let (look, black_and_white) = fit(raw, &t); + if let Some(look) = look { + return (xy, t, Some(look)); + } + let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) { + Some(camera) => { + camera.fill(&mut raw.color); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); + } + start(raw) + } + None => (xy, t), + }; + // A black-and-white JPEG first tries its existing tone fit on the chosen colour. + let look = if black_and_white { monochrome(raw, &t) } else { None }; + (xy, t, look.or_else(|| dark(raw, &t))) +} + +/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo +/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered +/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them). +pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option { + if lightcraft_raw::color::has_matrix(&raw.color) { + return None; + } + // the white the source shows, decided before `starting_colour` may fill in measured matrices + let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw)); + // (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling) + let (_, t, look) = starting_colour(raw, bytes, None, None); + dng_colour(&raw.color, &white, &t, look.as_ref()) +} + +/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens +/// with, when the file has no colour matrices (issue #1745: a Z 9 NEF's DNG had no `ColorMatrix1`, and other raw +/// converters refused it). The lens corrections stay in the DNG; only the fit sees the raw without them, as when +/// the photo opens. +pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) { + if lightcraft_raw::color::has_matrix(&raw.color) { + return; + } + let list3 = raw.opcodes.list3.clone(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let tags = starting_colour_tags(raw, bytes); + raw.opcodes.list3 = list3; + if let Some(tags) = tags { + raw.color = tags; + } +} + +/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward +/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation +/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table +/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two +/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo +/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). +/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]), +/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices +/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges +/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green. +/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them. +/// `None` when the white balance or the matrix can't give tags. +pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option { + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes + // into the baseline exposure + let white = m.apply([1.0; 3]); + let k = (white[0] + white[1] + white[2]) / 3.0; + if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) { + return None; + } + let m = Mat3(m.0.map(|r| r.map(|v| v / k))); + let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); + let wb = t.wb.map(f64::from); + let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); + let (illuminant, color_matrix) = match declared { + crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix), + crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]), + crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]), + }; + if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) { + return None; + } + Some(ColorData { + illuminant, + color_matrix, + // a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white + forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)], + camera_calibration: [None, None], + analog_balance: None, + as_shot_neutral: Some(neutral), + as_shot_white_xy: None, + baseline_exposure: t.baseline_exposure + k.log2(), + baseline_sharpness: tags.baseline_sharpness, + kelvin_presets: None, + profile: ProfileLook { + hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], + tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), + ..Default::default() + }, + }) +} + +/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per +/// channel. `None` when the model gives no positive neutral there. +fn declaring(xy: Xy, neutral: [f64; 3]) -> Option { + let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy); + let n = g.apply(xy.to_xyz()); + if !n.iter().all(|v| v.is_finite() && *v > 1e-9) { + return None; + } + Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g)) +} + +/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the +/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). +fn dng_tone_curve(tone: &CameraTone) -> Option { + const N: usize = 256; + let samples = (0..=N).map(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32); + [x, tone.apply(x).min(1.0)] + }); + let flat: Vec = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect(); + ToneCurve::from_tag(&flat) +} diff --git a/crates/engine/src/camera_preview/hue_sat.rs b/crates/engine/src/camera_preview/hue_sat.rs new file mode 100644 index 000000000..7e92ba519 --- /dev/null +++ b/crates/engine/src/camera_preview/hue_sat.rs @@ -0,0 +1,271 @@ +use super::mono::to_prophoto; +use super::pairs::{displayed, luma}; +use super::*; + +/// [`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 +/// its colour; in between the two are blended, so noisy shadows show no step. +pub(super) const MIN_LUMINANCE_KEPT: f32 = 0.25; + +pub(super) const NO_LUMINANCE_KEPT: f32 = 0.15; + +/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to +/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between. +pub(super) fn restore_weight(share: f32) -> f32 { + if !share.is_finite() { + return 0.0; + } + let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes +/// hue and saturation only: luminance is restored, the camera tone curve owns it. +pub(crate) struct HueSat<'a> { + table: &'a HsvTable, + pub(super) to: [[f32; 3]; 3], + pub(super) from: [[f32; 3]; 3], +} + +impl<'a> HueSat<'a> { + pub fn new(table: &'a HsvTable) -> Option> { + let to = to_prophoto(); + Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() }) + } + + #[inline] + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; + let out = mul(&self.from, self.table.apply(mul(&self.to, rgb))); + let (before, after) = (luminance_2020(rgb), luminance_2020(out)); + // restoring the luminance divides by the table's: near black, where noise makes some channels + // negative, the table can bring it close to 0, and the gain would turn the pixel white (#820) + if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) { + return rgb; + } + let w = restore_weight(after / before); + if w >= 1.0 { + return out.map(|v| v * before / after); + } + if w <= 0.0 { + return rgb; + } + std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) + } + + /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through + /// [`HsvTable::apply_lanes`], with the guards as selects. + #[inline] + pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { + let mut out = [[0.0f32; N]; 3]; + for (row, out) in m.iter().zip(&mut out) { + for l in 0..N { + out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; + } + } + out + }; + let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); + let mut restored = rgb; + for l in 0..N { + let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); + let (before, after) = (luminance_2020(input), luminance_2020(out)); + let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); + let w = restore_weight(after / before); + for (c, restored) in restored.iter_mut().enumerate() { + let full = out[c] * before / after; + let blend = input[c] + (full - input[c]) * w; + // (`w >= 1` and `w <= 0` exclude each other) + restored[l] = if untouched | (w <= 0.0) { + input[c] + } else if w >= 1.0 { + full + } else { + blend + }; + } + } + restored + } +} + +/// HSV hue (degrees) and saturation; `None` for black or non-finite colours. +pub(super) fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> { + let max = p[0].max(p[1]).max(p[2]); + let min = p[0].min(p[1]).min(p[2]); + if !(max.is_finite() && min.is_finite()) || max <= 0.0 { + return None; + } + let d = max - min; + if d <= 0.0 { + return Some((0.0, 0.0)); + } + let h = if max == p[0] { + ((p[1] - p[2]) / d).rem_euclid(6.0) + } else if max == p[1] { + (p[2] - p[0]) / d + 2.0 + } else { + (p[0] - p[1]) / d + 4.0 + }; + Some((h * 60.0, d / max)) +} + +/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns +/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately. +const TABLE_HUES: usize = 72; + +const TABLE_SATS: usize = 5; + +/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward +/// orange but bright yellow-greens toward green, which one shift per hue averages away. +const TABLE_VALS: usize = 5; + +/// Kernel widths around each table node (hue in degrees, saturation, encoded value). +const KERNEL_HUE: f64 = 2.5; + +const KERNEL_SAT: f64 = 0.15; + +const KERNEL_VAL: f64 = 0.12; + +/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity. +const SHRINK_WEIGHT: f64 = 5.0; + +/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue, +/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples. +/// Saturation 0 stays identity, so neutrals are never tinted. +pub(super) fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option { + let to = to_prophoto(); + let samples: Vec<(f64, f64, f64, f64, f64)> = pairs + .iter() + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .filter_map(|(_, (x, y))| { + let p = to.apply(matrix.apply(*x)); + let (hp, sp) = hue_saturation(p)?; + let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32)); + let (ht, st) = hue_saturation(to.apply(*y))?; + // hue is meaningless near neutral + if sp < 0.08 || st < 0.02 { + return None; + } + let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0); + let log_scale = (st / sp).ln().clamp(-0.7, 0.7); + Some((hp, sp.min(1.0), value, shift, log_scale)) + }) + .collect(); + if samples.len() < 64 { + return None; + } + // Samples by table hue step: a node only looks at hues within its kernel's reach. + let step = 360.0 / TABLE_HUES as f64; + let mut by_hue: Vec> = vec![Vec::new(); TABLE_HUES]; + for sample in samples { + if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) { + bin.push(sample); + } + } + let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1; + use rayon::prelude::*; + let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS) + .into_par_iter() + .map(|index| { + let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS); + if s == 0 { + return [0.0, 1.0, 1.0]; + } + let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64); + let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0); + for offset in 0..=2 * reach { + let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue }; + for &(hp, sp, vp, dh, ls) in bin { + let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0; + if dhue.abs() > 4.0 * KERNEL_HUE { + continue; + } + let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2); + let k = (-0.5 * d2).exp() * sp; + weight += k; + shift += k * dh; + log_scale += k * ls; + } + } + if weight <= 0.0 { + return [0.0, 1.0, 1.0]; + } + let shrink = 1.0 / (weight + SHRINK_WEIGHT); + [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0] + }) + .collect(); + data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable { + hue_divisions: TABLE_HUES, + sat_divisions: TABLE_SATS, + val_divisions: TABLE_VALS, + data, + srgb_value: true, + }) +} + +/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node +/// keeps half of its estimate. +const CHROMA_KERNEL: f64 = 0.08; + +const CHROMA_SHRINK: f64 = 2.0; + +/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds +/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as +/// the distance from neutral of luminance-normalised RGB. +pub(super) fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option { + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let scene = |x: &[f64; 3]| { + let p = look.matrix.apply(*x); + correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) + }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let predict = |x: &[f64; 3]| displayed(scene(x), &tone); + let chroma = |p: [f64; 3]| { + let y = luma(p); + (y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt()) + }; + let samples: Vec<(f64, f64, f64)> = pairs + .iter() + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .filter_map(|(_, (x, y))| { + let p = predict(x); + let (cp, cy) = (chroma(p)?, chroma(*y)?); + (cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln()))) + }) + .collect(); + if samples.len() < 64 { + return None; + } + let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N]; + for (j, node) in curve.iter_mut().enumerate() { + let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64; + let (mut weight, mut sum) = (0.0, 0.0); + for &(o, cp, log_ratio) in &samples { + let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp; + weight += k; + sum += k * log_ratio; + } + *node = (sum / (weight + CHROMA_SHRINK)).exp() as f32; + } + let fitted = look.tone.with_chroma(curve)?; + let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0); + let error = |map: &ToneMap| -> f64 { + pairs + .iter() + .step_by(3) + .map(|(x, y)| { + let p = displayed(scene(x), map); + (0..3).map(|c| (p[c] - y[c]).powi(2)).sum::() + }) + .sum() + }; + let (before, after) = (error(&tone), error(&with)); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}"); + } + (after.is_finite() && after < before).then_some(fitted) +} diff --git a/crates/engine/src/camera_preview/look_fit.rs b/crates/engine/src/camera_preview/look_fit.rs new file mode 100644 index 000000000..c1588093b --- /dev/null +++ b/crates/engine/src/camera_preview/look_fit.rs @@ -0,0 +1,273 @@ +use super::colour::{PROXY, file_local_look}; +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::*; + +/// 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) { + if !transform.matrix_is_fallback || !file_local_look(raw.format) { + return (None, false); + } + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) }; + let black_and_white = monochrome(&reference); + // A camera profile pooled from many photos knows colours this photo shows too little of; + // try its colour first and fit tone/chroma per photo (picture styles vary). + let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); + let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); + // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, + // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) { + Some(look) => look, + // A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve + // (`monochrome_look` does it without a profile). + None if colour.is_some() && black_and_white => { + let unclipped = without_clipped(&sensor, &clipped); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) }; + look + } + None => return (None, black_and_white), + }; + let look = if dro { with_dro_off_tone(look) } else { look }; + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}, DRO tone replaced {dro}", + raw.format, + look.matrix.0, + look.tone, + look.hue_sat.is_some(), + profile.is_some() + ); + } + (Some(look), black_and_white) +} + +/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in +/// the render's highlight reconstruction. +pub(super) const SENSOR_CLIP: f32 = 0.99; + +/// [`fit_look_with`] for a photo without Sony's DRO. +#[cfg(test)] +pub(super) fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { + fit_look_with(sensor, reference, clipped, colour, false) +} + +/// The photo's look, refitted without the proxy pixels whose sensor values are clipped. +/// +/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip +/// level while the others keep rising, a white overcast sky comes out magenta or blue through the +/// camera matrix, while the camera JPEG shows it white. As correspondences such pixels teach the +/// colour matrix to pull colours toward grey, and the chroma curve to remove colour at their +/// display luminance (on a Z 6II forest under a clipped sky the curve cut colourfulness above +/// display luminance 0.4 to 13–19%, and every one of its samples there was clipped). The render +/// rebuilds clipped highlights separately (`lightcraft_raw::highlight`), so the look is fitted on +/// what the sensor measured. +/// +/// The search on all pixels decides, as before, whether the photo gets a look and which attempt +/// of [`search_ordered`] gives it; only that attempt is refitted without the clipped pixels, and +/// when the refit misses the acceptance gates the look on all pixels is kept. So no photo loses +/// its look and none switches attempt (a refit that just clears the gate on all pixels must not +/// replace a look fitted away from edges). A photo that had no look gets one when the search +/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly +/// closer to the camera JPEG than the neutral fallback. +pub(super) fn fit_look_with( + sensor: &Rgb32f, + reference: &Rgb32f, + clipped: &[bool], + colour: Option<(Mat3, Option)>, + dro: bool, +) -> Option { + let unclipped = without_clipped(sensor, clipped); + // from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them + let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false }; + let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else { + return unclipped + .as_ref() + .and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright)) + .map(|(look, _)| look) + .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright)); + }; + let Some(unclipped) = unclipped else { return Some(look) }; + let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright); + if refit.is_none() && lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels"); + } + Some(refit.unwrap_or(look)) +} + +/// `sensor` with its clipped pixels set to NaN (left out of every training pair), or `None` when +/// none is clipped. +pub(super) fn without_clipped(sensor: &Rgb32f, clipped: &[bool]) -> Option { + if clipped.len() != sensor.data.len() || !clipped.iter().any(|c| *c) { + return None; + } + let mut out = sensor.clone(); + for (p, c) in out.data.iter_mut().zip(clipped) { + if *c { + *p = [f32::NAN; 3]; + } + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look: {} of {} proxy pixels have a clipped sensor channel", + clipped.iter().filter(|c| **c).count(), + clipped.len() + ); + } + Some(out) +} + +/// 1 where any channel of a camera-RGB pixel is at or above [`SENSOR_CLIP`], else 0. +pub(super) fn clip_mask(camera: &Rgb32f) -> Rgb32f { + let mut mask = camera.clone(); + mask.map_in_place(|p| if p.iter().any(|v| *v >= SENSOR_CLIP) { [1.0; 3] } else { [0.0; 3] }); + mask +} + +/// Sony's global tone curve with the Dynamic Range Optimizer off (Standard creative style), pooled +/// from 12 CC0 raw.pixls.us ARWs of 10 interchangeable-lens bodies and their camera JPEGs; the file +/// lists them and how the curve was derived (`assets/ATTRIBUTION.md`). Pooled the same way from 55 +/// private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance +/// 0.01 and 0.5. Issue #244, `docs/camera-preview-colour.md`. +const SONY_DRO_OFF_TONE: &str = include_str!("../../../../assets/camera-tone/sony-dro-off.json"); + +/// [`SONY_DRO_OFF_TONE`]'s curve (`None` only if the built-in file were invalid; a test checks it). +pub(super) fn sony_dro_off_tone() -> Option { + #[derive(serde::Deserialize)] + struct File { + knots: [[f32; 2]; 32], + } + static TONE: std::sync::OnceLock> = std::sync::OnceLock::new(); + *TONE.get_or_init(|| serde_json::from_str::(SONY_DRO_OFF_TONE).ok().and_then(|f| CameraTone::new(f.knots))) +} + +/// `look`, fitted to a camera JPEG brightened by Sony's Dynamic Range Optimizer, with its tone +/// curve lowered to Sony's without DRO ([`SONY_DRO_OFF_TONE`]) wherever that is darker. DRO only +/// brightens, so the fitted curve bounds the curve without it from above, everywhere: where Sony's +/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps +/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted. +pub(super) fn with_dro_off_tone(look: CameraLook) -> CameraLook { + CameraLook { tone: dro_off(&look.tone), ..look } +} + +/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker, +/// or `own` unchanged when it can't be. +pub(super) fn dro_off(own_tone: &CameraTone) -> CameraTone { + let Some(dro_off) = sony_dro_off_tone() else { return *own_tone }; + let own = own_tone.knots(); + let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]); + // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots + // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the + // extended highlights would end up above the fitted curve. Raise the second-last knot (never + // above the fitted curve) until the rate is at most the fitted curve's. + let ([a, b], last) = ([own[30], own[31]], knots[31]); + let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01); + let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]); + knots[30][1] = knots[30][1].max(floor).min(last[1]); + let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone }; + if !at_most(&tone, own_tone) { + return *own_tone; + } + tone +} + +/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone +/// table covers ([`ToneMap::camera`]). +pub(super) fn at_most(a: &CameraTone, b: &CameraTone) -> bool { + use lightcraft_pipeline::tone::{GREY, LUT_MAX_EV, LUT_MIN_EV, LUT_N}; + (0..LUT_N).all(|i| { + let x = GREY * 2f32.powf(LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32); + a.apply(x) <= b.apply(x) + 1e-5 + }) +} + +/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and +/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); +/// 128 MP also admits 100 MP medium-format previews. +pub(super) const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; + +/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model +/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a +/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not +/// clip more highlights than the plain render ([`clips_more`]). +pub(super) fn fit_dng_tone( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + lens: Option<&lightcraft_develop::EmbeddedLens>, +) -> Option { + // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. + let refuse = |why: &str| { + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE refused {why}"); + } + None + }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") }; + // As the loader renders it: the profile's tables around the baseline exposure. + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); + } + correct_vignetting(&mut sensor, lens, raw.orientation); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return refuse("preview"); + } + if !structure_agrees(&sensor, &reference) { + return refuse("structure"); + } + let unclipped = without_clipped(&sensor, &clipped); + let fit_on = unclipped.as_ref().unwrap_or(&sensor); + let colour = Some((Mat3::IDENTITY, None)); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let look = TONE_FITS.iter().find_map(|&tone| { + [false, true].into_iter().find_map(|away_from_edges| { + fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright) + }) + }); + let Some(look) = look else { return refuse("fit") }; + let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { + Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), + None => ToneMap::new(0.0, 0.0, 0.0), + }; + let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if clips_more(&sensor, &reference, &fitted, &plain) { + return refuse("clip"); + } + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE accepted"); + } + Some(look) +} + +/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain +/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the +/// camera's look, one that clips what neither does loses highlight detail. +pub(super) fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { + let near_white = |tone: &ToneMap| { + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + n as f64 / sensor.data.len().max(1) as f64 + }; + let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; + near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE +} + +/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the +/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the +/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. +pub(super) fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { + let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; + let (w, h) = (sensor.width as f64, sensor.height as f64); + let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + let width = sensor.width.max(1); + for (i, p) in sensor.data.iter_mut().enumerate() { + let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); + let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; + *p = p.map(|v| v * gain); + } +} diff --git a/crates/engine/src/camera_preview/mono.rs b/crates/engine/src/camera_preview/mono.rs new file mode 100644 index 000000000..cfac90c34 --- /dev/null +++ b/crates/engine/src/camera_preview/mono.rs @@ -0,0 +1,212 @@ +use super::colour::{PROXY, file_local_look}; +use super::hue_sat::HueSat; +use super::look_fit::{with_dro_off_tone, without_clipped}; +use super::pairs::{ + EDGE_CONTRAST, MIN_IMPROVEMENT, PARTIAL_MIN_RANK_CORRELATION, Pairs, collect_pairs, displayed, fit_tone_with_shadows, local_contrast, luma, + rank_correlation_ties, +}; +use super::proxy::{LensGeometry, proxies}; +use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold}; +use super::*; + +/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. +const GREY_SPREAD: f32 = 0.002; + +/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon +/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within +/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one. +pub(super) fn monochrome(reference: &Rgb32f) -> bool { + let coloured = reference + .data + .iter() + .filter(|p| { + let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + !p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD + }) + .count(); + reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len() +} + +/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera +/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo +/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker +/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve. +/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and +/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The +/// chroma curve stays neutral: the JPEG has no colourfulness to learn. +pub(super) fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { + if !file_local_look(raw.format) { + return None; + } + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; + if !monochrome(&reference) { + return None; + } + // Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let unclipped = without_clipped(&sensor, &clipped); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?; + let look = if dro { with_dro_off_tone(look) } else { look }; + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}", + raw.format, + look.tone, + if transform.matrix_is_fallback { "fallback" } else { "spectral" } + ); + } + Some(look) +} + +/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's +/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of +/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture +/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below +/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than +/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance). +pub(super) fn fit_tone_only_with( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + highlights: &HighlightCheck, +) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights)) +} + +/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas. +#[cfg(test)] +pub(super) fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }) +} + +fn fit_tone_only_on( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + highlights: &HighlightCheck, +) -> Option { + // every pair counts: the JPEG has no colour to require + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; + fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights) +} + +/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs. +/// Try only after every existing look fails. Keep the colour of the source transform, +/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG +/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights. +pub(super) fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option { + if (sensor.width, sensor.height) != (reference.width, reference.height) + || sensor.data.len() != reference.data.len() + || clipped.len() != sensor.data.len() + || sensor.data.is_empty() + || sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite())) + || reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85) + { + return None; + } + let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count(); + if (dark as f64) < reference.data.len() as f64 * 0.9 { + return None; + } + // no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold + let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }; + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| { + let pairs: Pairs = sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, (input, output))| { + // The same signal floors as the camera toe, without colour ratios or counts. + !clipped.get(*i).copied().unwrap_or(true) + && input.iter().all(|v| *v > 0.0001 && *v < 1.5) + && (0.0003..0.85).contains(&luminance_2020(**output)) + && edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit) + }) + .map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from))) + .collect(); + if pairs.len() < 256 { + return None; + } + let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?; + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) { + if *clipped { + let target = luma(target.map(f64::from)); + let before = luma(displayed(input.map(f64::from), &neutral)); + let after = luma(displayed(input.map(f64::from), &tone)); + if !after.is_finite() || (after - target).abs() > (before - target).abs() { + return None; + } + } + } + Some(look) + }) +} + +fn fit_tone_only_pairs( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + pairs: &Pairs, + dark: bool, + highlights: &HighlightCheck, +) -> Option { + let kind = if dark { "near-black scene" } else { "black-and-white JPEG" }; + let related = rank_correlation_ties(pairs); + if related < PARTIAL_MIN_RANK_CORRELATION { + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture"); + } + return None; + } + let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None)); + let correction = hue_sat.as_ref().and_then(HueSat::new); + let coloured = |x: [f64; 3]| { + let p = matrix.apply(x); + correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) + }; + let tone = if dark { + // Split this wider set once. The camera toe's separate colour/extra split belongs to + // the original colour pairs and would put some of these held-out pixels into training. + fit_tone( + pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(), + )? + } else { + fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)? + }; + let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; + if !highlights_hold(&look.tone, highlights, coloured) { + return None; + } + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); + let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target)); + before += (fallback - target).powi(2); + after += (toned - target).powi(2); + before_encoded += (encoded(fallback) - encoded(target)).powi(2); + after_encoded += (encoded(toned) - encoded(target)).powi(2); + samples += 1; + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", + (before / samples.max(1) as f64).sqrt(), + (after / samples.max(1) as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } + ); + } + let better = + after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; + (samples > 0 && better).then_some(look) +} + +/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in. +pub(super) fn to_prophoto() -> Mat3 { + PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) +} diff --git a/crates/engine/src/camera_preview/pairs.rs b/crates/engine/src/camera_preview/pairs.rs new file mode 100644 index 000000000..2fce1a871 --- /dev/null +++ b/crates/engine/src/camera_preview/pairs.rs @@ -0,0 +1,499 @@ +use super::hue_sat::{HueSat, fit_chroma, fit_hue_sat}; +use super::proxy::DARK_FLOOR; +#[cfg(test)] +use super::tone::highlights; +use super::tone::{HighlightCheck, ToneFit, highlights_hold}; +use super::*; + +/// 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; + +/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public +/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed +/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve +/// cannot follow): visibly better renders that 0.055 rejected. +const MAX_HOLDOUT_RMS: f64 = 0.10; + +pub(super) fn luma(p: [f64; 3]) -> f64 { + p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593 +} + +/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`). +pub(super) fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] { + let scene = scene.map(|v| v.max(0.0)); + let y = luma(scene); + if y <= 0.0 { + return [0.0; 3]; + } + let o = f64::from(tone.apply(y as f32)); + let k = f64::from(tone.chroma_scale(o as f32)); + scene.map(|v| o + (v * o / y - o) * k) +} + +#[cfg(test)] +pub(super) fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + fit_pairs_with(sensor, reference, None) +} + +/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges). +pub(super) fn local_contrast(image: &Rgb32f, i: usize) -> f32 { + let (w, h) = (image.width, image.height); + if w == 0 { + return f32::INFINITY; + } + let (x, y) = (i % w, i / w); + let (mut lo, mut hi) = (f32::INFINITY, 0f32); + for row in y.saturating_sub(1)..(y + 2).min(h) { + for col in x.saturating_sub(1)..(x + 2).min(w) { + let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p)); + lo = lo.min(l); + hi = hi.max(l); + } + } + if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY } +} + +/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights +/// (for the chroma curve); `None` when too few, or the photo has too little colour. With +/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio +/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated +/// colours (see [`EDGE_CONTRAST`]). +pub(super) type Pair = ([f64; 3], [f64; 3]); + +pub(super) type Pairs = Vec; + +pub(super) fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { + if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { + return None; + } + let mut pairs = Vec::new(); + // Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only. + let mut bright = Vec::new(); + let mut colour = 0; + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } + let y = luminance_2020(*output); + if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) { + continue; + } + if (0.015..=1.0).contains(&y) { + bright.push((input.map(f64::from), output.map(f64::from))); + } + let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) }; + if !colour_pair_above(*input, *output, floors) { + continue; + } + let min = output.iter().copied().fold(f32::INFINITY, f32::min); + let max = output.iter().copied().fold(0.0, f32::max); + colour += usize::from(max - min > min_chroma); + pairs.push((input.map(f64::from), output.map(f64::from))); + } + (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) +} + +/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. +pub(super) fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { + colour_pair_above(input, output, (0.015, 0.004)) +} + +/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or +/// [`DARK_FLOOR`] for the tone-only fit of a DNG). +fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> bool { + input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) + && output.iter().all(|v| *v > clo && *v < 0.98) + && (ylo..0.85).contains(&luminance_2020(output)) +} + +/// Fit tone independently of colour eligibility when enough dark JPEG signal is available. +/// The original pairs and their split still own the colour fit and its acceptance gates. +/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and +/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise. +pub(super) fn fit_tone_with_shadows( + fit: ToneFit, + pairs: &[([f64; 3], [f64; 3])], + sensor: &Rgb32f, + reference: &Rgb32f, + edge_limit: Option, + colour: impl Fn([f64; 3]) -> [f64; 3], +) -> Option { + let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y)); + let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect()); + let mut eligible = Vec::new(); + let (mut colour_index, mut extra_index) = (0, 0); + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } + // About one 8-bit sRGB code above black: don't learn a flat black segment from + // JPEG compression. The sensor proxy averages many samples before this test. + if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5) + || !output.iter().all(|v| v.is_finite() && *v >= 0.0) + || !(0.0003..0.85).contains(&luminance_2020(*output)) + { + continue; + } + // A colour holdout must never enter tone training just because the wider sample set + // changes its index. Split the extra samples separately, keeping every colour index. + let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index }; + let held = *index % 3 == 0; + *index += 1; + let (x, y) = luminances(input.map(f64::from), output.map(f64::from)); + if x.is_finite() && x > 0.0 { + eligible.push((x, y, held)); + } + } + let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count(); + let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect(); + // Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank, + // so a single quantized grey also keeps the original curve. + if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION { + return original; + } + let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect(); + let Some(candidate) = fit.fit(training) else { return original }; + let error = |tone: &CameraTone| -> f64 { + held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum() + }; + // A wider histogram must also help independent dark pixels, not just redistribute the + // knots. The usual held-out gates still check the completed look on the colour pairs. + if let Some(old) = original { + let (before, after) = (error(&old), error(&candidate)); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}"); + } + if !after.is_finite() || after >= before * 0.99 { + return original; + } + } + Some(candidate) +} + +/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs. +pub(super) fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { + let mut gram = [[0.0; 3]; 3]; + let mut cross = [[0.0; 3]; 3]; + for (i, (x, y)) in pairs.iter().enumerate() { + if i % 3 == 0 { + continue; + } + let (lx, ly) = (luma(*x), luma(*y)); + if lx <= 0.0 || ly <= 0.0 { + continue; + } + for row in 0..3 { + for col in 0..3 { + // Normalising by luminance prevents a camera S-curve from corrupting colour. + gram[row][col] += x[row] * x[col] / (lx * lx); + cross[row][col] += y[row] * x[col] / (ly * lx); + } + } + } + let trace: f64 = (0..3).map(|i| gram[i][i]).sum(); + let inverse = Mat3(gram).inverse()?; + let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::(); + if trace <= 0.0 || !condition.is_finite() || condition > 1e6 { + return None; + } + let regularization = trace * 1e-4; + for i in 0..3 { + gram[i][i] += regularization; + cross[i][i] += regularization; + } + let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?); + matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix) +} + +/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt +/// of [`search_ordered`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", +/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px +/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched +/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction +/// off). Away from edges: 0.049. Issue #232. +pub(super) const EDGE_CONTRAST: f32 = 3.0; + +/// The look on all pixels (the search of [`search_ordered`] with [`TONE_FITS`]), as before +/// clipped pixels were left out. +#[cfg(test)] +pub(super) fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_pairs_ordered(sensor, reference, colour, TONE_FITS) +} + +/// The look from [`search_ordered`] without its attempt. +#[cfg(test)] +pub(super) fn fit_pairs_ordered( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + order: &[ToneFit], +) -> Option { + search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false }) + .map(|(look, _)| look) +} + +/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is +/// used. Quantile matching comes first because it cannot produce flat steps; the +/// conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) its +/// slightly lower held-out error is what clears the gates, and a photo must not lose its look to +/// the change of fit. +pub(super) const TONE_FITS: &[ToneFit] = &[ToneFit::Quantile, ToneFit::ConditionalMedian]; + +/// One attempt of the search: tone fit, the camera profile's colour or the photo's own, all pixels +/// or only those away from edges. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) struct Attempt { + pub(super) tone: ToneFit, + pub(super) profile: bool, + pub(super) away_from_edges: bool, + /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. + pub(super) dark: bool, +} + +/// The photo's look and the attempt that produced it: its own matrix (with and without a +/// hue/saturation table), or the given colour model (a camera profile's, in the sensor proxy's +/// space), each completed with a tone and chroma curve fitted to this photo. Per tone fit in +/// `order`: the camera profile's colour (when there is one), then the photo's own, as a different +/// picture style can make a camera profile fail the gates; each fitted on all pixels and, when that +/// fails the gates, once more on the pixels away from edges (same gates): there colour pairs stay +/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole +/// search runs with one tone fit before the next is tried, so the conditional-median fallback +/// accepts exactly what the previous releases accepted. +pub(super) fn search_ordered( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + order: &[ToneFit], + bright: &HighlightCheck, +) -> Option<(CameraLook, Attempt)> { + let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] }; + order.iter().find_map(|&tone| { + profiles.iter().find_map(|&profile| { + [false, true].into_iter().find_map(|away_from_edges| { + let attempt = Attempt { tone, profile, away_from_edges, dark: false }; + fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt)) + }) + }) + }) +} + +pub(super) fn fit_attempt( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + attempt: Attempt, + bright: &HighlightCheck, +) -> Option { + let colour = if attempt.profile { colour.clone() } else { None }; + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright) +} + +fn fit_pairs_on( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + edge_limit: Option, + tone_fit: ToneFit, + dark: bool, + highlights: &HighlightCheck, +) -> Option { + // A known colour model needs enough signal for tone fitting, not a scene rich enough to + // learn a new colour matrix. Still reject monochrome references. + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; + let candidates = match colour { + Some(given) => vec![given], + None => { + let matrix = fit_matrix(&pairs)?; + vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)] + } + }; + // Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by + // error in gamma-encoded display values (closer to what is seen: in linear values a slightly + // missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear. + let mut best: Option<(f64, f64, usize, CameraLook)> = None; + for (matrix, hue_sat) in candidates { + let correction = hue_sat.as_ref().and_then(HueSat::new); + let colour = |x: [f64; 3]| { + let p = matrix.apply(x); + correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) + }; + let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue }; + let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() }; + if let Some(tone) = fit_chroma(&bright, &look) { + look.tone = tone; + } + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if !highlights_hold(&look.tone, highlights, colour) { + continue; + } + let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let corrected = displayed(colour(*x), &tone); + for c in 0..3 { + linear += (corrected[c] - target[c]).powi(2); + perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2); + samples += 1; + } + } + if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) { + best = Some((perceptual, linear, samples, look)); + } + } + let (_, after, samples, look) = best?; + let original_tone = ToneMap::new(0.0, 0.0, 0.0); + let before: f64 = pairs + .iter() + .step_by(3) + .map(|(x, target)| { + let original = displayed(*x, &original_tone); + (0..3).map(|c| (original[c] - target[c]).powi(2)).sum::() + }) + .sum(); + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{}{})", + (before / samples as f64).sqrt(), + (after / samples as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" }, + if tone_fit == ToneFit::ConditionalMedian { ", conditional-median tone" } else { "" } + ); + } + if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { + return None; + } + Some(look) +} + +/// Weight of the photo's own colour matrix in a [`fit_partial`] look; the rest is the identity (the +/// neutral fallback's generic camera ≈ sRGB colour). On the CC0 archive's proxies, with the +/// matrix at full weight 2 of the 23 colourful photos without a look came out more than 2° further +/// from the camera's hues than the neutral fallback, at half weight none (median hue error 9.6° with +/// the generic colour, which the tone and chroma curves make visible, 5.1° at half weight). +const PARTIAL_MATRIX_WEIGHT: f64 = 0.5; + +/// Smallest rank correlation of scene and camera JPEG luminance over a [`fit_partial`] look's pairs: +/// the JPEG must show the same picture. A look that only matches the brightness distribution of an +/// unrelated picture can still beat the dark neutral fallback pixel for pixel. On the CC0 archive's +/// proxies the accepted full looks have 0.55 or more, lens-corrected compacts without one 0.53–0.77, +/// three old Coolpix files whose raw decodes wrongly 0.07–0.27. +pub(super) const PARTIAL_MIN_RANK_CORRELATION: f64 = 0.5; + +/// Spearman rank correlation of the scene's and the JPEG's luminance over `pairs`. +fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 { + let ranks = |values: Vec| { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); + let mut ranks = vec![0.0; values.len()]; + for (rank, i) in order.into_iter().enumerate() { + ranks[i] = rank as f64; + } + ranks + }; + let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); + let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); + let mean = (pairs.len() as f64 - 1.0) / 2.0; + let (covariance, variance) = + scene.iter().zip(&jpeg).fold((0.0, 0.0), |(c, v), (a, b)| (c + (a - mean) * (b - mean), v + (a - mean) * (a - mean))); + if variance > 0.0 { covariance / variance } else { 0.0 } +} + +/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without +/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order, +/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related. +pub(super) fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 { + let ranks = |values: Vec| { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); + let mut ranks = vec![0.0; values.len()]; + let mut start = 0; + while start < order.len() { + let mut end = start + 1; + while end < order.len() && values[order[end]] == values[order[start]] { + end += 1; + } + let mean = (start + end - 1) as f64 / 2.0; + for &i in &order[start..end] { + ranks[i] = mean; + } + start = end; + } + ranks + }; + let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); + let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); + let mean = (pairs.len() as f64 - 1.0) / 2.0; + let (covariance, scene_variance, jpeg_variance) = scene + .iter() + .zip(&jpeg) + .fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean))); + if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 } +} + +/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of +/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs +/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered +/// 9–22 L* too dark), the chroma curve (the fallback kept about a third of the camera's +/// colourfulness), and, when the scene has enough colour to learn one (the full look's condition), +/// the photo's own colour matrix at [`PARTIAL_MATRIX_WEIGHT`]. No hue/saturation table. Tried on +/// all pixels and then, like the full look, on the pixels away from edges; used only when it cuts +/// the held-out squared error to below [`MIN_IMPROVEMENT`] of the neutral fallback's, in linear +/// and in gamma-encoded display values, and when the JPEG shows the same picture +/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly +/// closer to the camera JPEG than what the photo would get otherwise. +pub(super) fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights)) +} + +fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option, highlights: &HighlightCheck) -> Option { + // as for a known colour model: enough signal for the tone, but no monochrome reference + let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; + let related = rank_correlation(&pairs); + if related < PARTIAL_MIN_RANK_CORRELATION { + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look partial: luminance rank correlation {related:.3}, not the same picture"); + } + return None; + } + let spread = |y: &[f64; 3]| y.iter().copied().fold(f64::NEG_INFINITY, f64::max) - y.iter().copied().fold(f64::INFINITY, f64::min); + let colourful = pairs.iter().filter(|(_, y)| spread(y) > 0.05).count() >= pairs.len() / 20; + let matrix = match colourful.then(|| fit_matrix(&pairs)).flatten() { + Some(own) => Mat3(std::array::from_fn(|r| { + std::array::from_fn(|c| Mat3::IDENTITY.0[r][c] + PARTIAL_MATRIX_WEIGHT * (own.0[r][c] - Mat3::IDENTITY.0[r][c])) + })), + None => Mat3::IDENTITY, + }; + let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?; + let mut look = CameraLook { matrix, tone, hue_sat: None }; + if let Some(tone) = fit_chroma(&bright, &look) { + look.tone = tone; + } + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) { + return None; + } + let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); + let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let (fallback, partial) = (displayed(*x, &neutral), displayed(matrix.apply(*x), &tone)); + for c in 0..3 { + before += (fallback[c] - target[c]).powi(2); + after += (partial[c] - target[c]).powi(2); + before_encoded += (encoded(fallback[c]) - encoded(target[c])).powi(2); + after_encoded += (encoded(partial[c]) - encoded(target[c])).powi(2); + samples += 1; + } + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look partial (tone{}{}) holdout RMS {:.5} -> {:.5} ({samples} channels{})", + if look.tone.chroma().iter().any(|k| *k != 1.0) { ", chroma" } else { "" }, + if matrix != Mat3::IDENTITY { ", damped matrix" } else { "" }, + (before / samples.max(1) as f64).sqrt(), + (after / samples.max(1) as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } + ); + } + let better = + after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; + (samples > 0 && better).then_some(look) +} diff --git a/crates/engine/src/camera_preview/proxy.rs b/crates/engine/src/camera_preview/proxy.rs new file mode 100644 index 000000000..81f170a87 --- /dev/null +++ b/crates/engine/src/camera_preview/proxy.rs @@ -0,0 +1,442 @@ +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::*; + +/// 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 +/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty. +pub(super) const MIN_PREVIEW_FRACTION: f64 = 0.1; + +/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark +/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. +pub(super) const DARK_FLOOR: (f32, f32) = (0.004, 0.001); + +/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's +/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits +/// that clip about 1% more than the camera does and passes the ones that clip like the camera). +pub(super) const CLIP_TOLERANCE: f64 = 0.001; + +/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. +const MIN_STRUCTURE: f64 = 0.85; + +/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance +/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) +/// correlates better. +pub(super) fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { + let (w, h) = (sensor.width, sensor.height); + if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { + return false; + } + let log = |img: &Rgb32f| -> Vec { + let l: Vec = + img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (mut sum, mut n) = (0.0, 0.0); + for yy in y.saturating_sub(2)..(y + 3).min(h) { + for xx in x.saturating_sub(2)..(x + 3).min(w) { + if let Some(v) = l.get(yy * w + xx) { + sum += v; + n += 1.0; + } + } + } + sum / f64::max(n, 1.0) + }) + .collect() + }; + let (a, b) = (log(sensor), log(reference)); + const M: usize = 3; + let corr = |dx: isize, dy: isize| -> f64 { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in M..h - M { + for x in M..w - M { + let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); + let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; + n += 1.0; + sa += u; + sb += v; + saa += u * u; + sbb += v * v; + sab += u * v; + } + } + let var = (saa - sa * sa / n) * (sbb - sb * sb / n); + if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } + }; + let at_zero = corr(0, 0); + let (mut best, mut shift) = (at_zero, (0isize, 0isize)); + for dy in -(M as isize)..=M as isize { + for dx in -(M as isize)..=M as isize { + let c = corr(dx, dy); + if c > best + 1e-9 { + (best, shift) = (c, (dx, dy)); + } + } + } + at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 +} + +/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s +/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), +/// and per proxy pixel whether any sensor sample it covers is clipped. +pub(super) fn proxies( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + size: usize, + min_fraction: f64, + lens: Option<&LensGeometry>, +) -> Option<(Rgb32f, Rgb32f, Vec)> { + let edge = (2 * size).max(384) as u32; + let decoded = crate::files::decode_raw_preview( + bytes, + lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, + None, + )?; + let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); + // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) + { + return None; + } + let mut reference = decoded.to_working(); + if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { + return None; + } + let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02; + if !matches(crop.width, crop.height, reference.width, reference.height) { + // Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio + if !matches(a.width, a.height, reference.width, reference.height) { + return None; + } + let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64); + let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize); + let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize); + if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height { + return None; + } + reference = reference.into_crop(x, y, w, h); + } + // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. + let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; + let mut sensor = sensor_proxy(raw, k, edge as usize)?; + let mut clipped = clip_mask(&sensor); + // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % + // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, + // kept below when it lines up with the JPEG better than the uncorrected one. + let warped = lens + .filter(|_| raw.format != RawFormat::Cr3) + .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); + let gain = 2f32.powf(transform.baseline_exposure as f32); + let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); + if raw.format == RawFormat::Cr3 { + // Orient the camera JPEG and the sensor identically before collecting pixel correspondences. + sensor = sensor.into_oriented(raw.orientation); + clipped = clipped.into_oriented(raw.orientation); + reference = reference.into_oriented(raw.orientation); + sensor.map_in_place(to_working); + // Canon's JPEG can be cropped differently from the sensor. Estimate only that common + // framing from edge directions, independently of the subsequent colour fit. + (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); + } + let mut sensor = fit(&sensor, size, size, Filter::Box); + let mut reference_small = reference_at(&reference, &sensor); + if let Some((warped, warped_clipped)) = warped { + let candidate = fit(&warped, size, size, Filter::Box); + let candidate_reference = reference_at(&reference, &candidate); + if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { + (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); + } + } + let reference = reference_small; + // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is + // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like + // the reference, so the mask pairs pixel for pixel. + let clipped: Vec = resize(&clipped, sensor.width, sensor.height, Filter::Box).data.iter().map(|p| p[0] > 0.0).collect(); + if raw.format != RawFormat::Cr3 { + sensor.map_in_place(to_working); + } + Some((sensor, reference, clipped)) +} + +/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it +/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's +/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs` +/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked +/// that the aspects agree within 2 %. +pub(super) fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f { + resize(reference, sensor.width, sensor.height, Filter::Box) +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct Cr3Framing { + pub(super) scale: f32, + /// Translation as a fraction of the oriented frame's width and height. + pub(super) offset: [f32; 2], +} + +impl Cr3Framing { + const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] }; + + pub(super) fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) { + let (w, h) = (w as f32, h as f32); + ((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1])) + } +} + +/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different +/// white balance and monotone camera tone; no target RGB values train this three-parameter fit. +/// A separate third of the edges must confirm a substantial improvement and a close match. +/// A weak, flat or unrelated preview leaves the sensor framing unchanged. +pub(super) fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() { + return None; + } + let sensor = fit(sensor, PROXY, PROXY, Filter::Box); + let (w, h) = (sensor.width, sensor.height); + if w < 16 || h < 16 { + return None; + } + let reference = fit(reference, w, h, Filter::Box); + if (reference.width, reference.height) != (w, h) { + return None; + } + let mut gradients = Rgb32f::new(w, h); + let mut edges = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let derivative = |image: &Rgb32f| { + [ + luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]), + luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]), + ] + }; + let d = derivative(&sensor); + gradients.data[y * w + x] = [d[0], d[1], 0.0]; + let d = derivative(&reference); + let magnitude = d[0].hypot(d[1]); + let value = luminance_2020(reference.data[y * w + x]); + if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 { + edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0)); + } + } + } + if edges.len() < 192 { + return None; + } + let score = |framing: Cr3Framing, held_out: bool| -> Option { + let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize); + for &(x, y, target, magnitude, holdout) in &edges { + if holdout != held_out { + continue; + } + total += 1; + let (sx, sy) = framing.source(x, y, w, h); + if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 { + continue; + } + let sample = gradients.sample_bilinear(sx, sy); + let length = sample[0].hypot(sample[1]); + if !length.is_finite() || length <= 0.01 { + continue; + } + let k = f64::from(magnitude.min(0.25)); + agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude)); + weight += k; + valid += 1; + } + (valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight) + }; + let before = score(Cr3Framing::IDENTITY, false)?; + let mut best = (before, Cr3Framing::IDENTITY); + // At most 10% zoom and two proxy pixels of translation; a coarse search followed by one + // local refinement prevents unbounded optimisation on the photograph's contents. + for s in -10..=10 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] }; + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let coarse = best.1; + for s in -5..=5 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { + scale: coarse.scale + s as f32 * 0.002, + offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32], + }; + if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 { + continue; + } + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?); + if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 { + return None; + } + if lightcraft_pipeline::profiling() { + eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1); + } + Some(best.1) +} + +/// The sensor proxy and its clip mask (same size), both moved to the camera JPEG's framing. +fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rgb32f, Rgb32f) { + let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return (sensor, clipped) }; + let apply = |image: &Rgb32f| { + let (w, h) = (image.width, image.height); + let mut aligned = Rgb32f::new(w, h); + for (i, pixel) in aligned.data.iter_mut().enumerate() { + let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { image.sample_bilinear(x, y) } else { [f32::NAN; 3] }; + } + aligned + }; + (apply(&sensor), apply(&clipped)) +} + +/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the +/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own +/// colour matrices, other files or unusable previews. +pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option> { + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + return None; + } + let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?; + let to_camera = transform.matrix.inverse()?; + // clipped sensor pixels don't record the scene's colour (see `fit_look`) + let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); + let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?; + Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) +} + +/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table) +/// fitted to pairs pooled from many photos. +pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option)> { + let matrix = fit_matrix(pairs)?; + Some((matrix, fit_hue_sat(pairs, &matrix))) +} + +/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of +/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without +/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct LensGeometry { + /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. + pub(super) k: [f64; 4], + /// The optical centre, relative to the active area (as the opcode has it). + pub(super) center: [f64; 2], +} + +pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option { + use lightcraft_raw::Opcode; + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); + if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { + return None; + } + ops.into_iter().find_map(|op| match op { + Opcode::WarpRectilinear { planes, center } => { + let p = planes.get(1).or(planes.first())?; + let k = [p[0], p[1], p[2], p[3]]; + (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1])) + .then_some(LensGeometry { k, center: *center }) + } + _ => None, + }) +} + +/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both +/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. +pub(super) fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { + let (w, h) = (a.width, a.height); + if (b.width, b.height) != (w, h) || w < 3 || h < 3 { + return -1.0; + } + let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::>(); + let high = |l: &[f64]| -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if x == 0 || y == 0 || x + 1 == w || y + 1 == h { + return f64::NAN; + } + let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; + l[i] - around.iter().map(|&j| l[j]).sum::() / 8.0 + }) + .collect() + }; + let (ha, hb) = (high(&lum(a)), high(&lum(b))); + let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); + if pairs.len() < 64 { + return -1.0; + } + let n = pairs.len() as f64; + let (ma, mb) = (pairs.iter().map(|p| p.0).sum::() / n, pairs.iter().map(|p| p.1).sum::() / n); + let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); + let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); + let r = cov / (va * vb).sqrt().max(1e-12); + if r.is_finite() { r } else { -1.0 } +} + +/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is +/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` +/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another +/// size (the demosaiced fallback of linear raws). +pub(super) fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option { + let a = raw.active_area; + let c = raw.crop.clipped(a.width, a.height); + let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; + let (w, h) = (img.width, img.height); + if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { + return None; + } + let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); + let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); + let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); + let [k0, k1, k2, k3] = lens.k; + let kf = k as f64; + let (x0, y0) = (c.x as f64, c.y as f64); + let mut out = Rgb32f::new(w, h); + for (i, px) in out.data.iter_mut().enumerate() { + // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor + let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); + let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); + let r2 = dx * dx + dy * dy; + let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); + *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { + img.sample_bilinear(sx as f32, sy as f32) + } else { + [f32::NAN; 3] + }; + } + Some(out) +} + +pub(super) fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { + Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { + Some(sensor) => sensor, + // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution. + None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => { + fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box) + } + None => return None, + }) +} diff --git a/crates/engine/src/camera_preview/tests.rs b/crates/engine/src/camera_preview/tests.rs new file mode 100644 index 000000000..d8442589d --- /dev/null +++ b/crates/engine/src/camera_preview/tests.rs @@ -0,0 +1,2429 @@ +use super::*; + +fn steep_toe_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + // A broad scene with a steep camera toe below the old JPEG floor of 0.015. + // Permute brightness so the held-out pixels span the same range independently. + let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25; + let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025]; + *src = rgb.map(|v| v * y / luminance_2020(rgb)); + let display = 3.0 * y * (y / (y + 0.02)).powi(2); + *dst = src.map(|v| v * display / y); + } + (sensor, reference) +} + +#[test] +fn camera_tone_follows_the_jpegs_deep_shadows() { + let (sensor, reference) = steep_toe_scene(); + let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + assert!(dark.len() > 256); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "deep-shadow luminance error {error}"); + assert_eq!(look.matrix, Mat3::IDENTITY); +} + +#[test] +fn colour_holdouts_stay_out_of_the_wider_tone_training() { + let (sensor, reference) = steep_toe_scene(); + let fit = |reference: &Rgb32f| { + let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap(); + fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() + }; + let expected = fit(&reference); + let mut changed = reference.clone(); + let mut index = 0; + for (x, y) in sensor.data.iter().zip(&mut changed.data) { + if colour_pair(*x, *y) { + if index % 3 == 0 { + *y = y.map(|v| v * 1.1); + assert!(colour_pair(*x, *y)); + } + index += 1; + } + } + assert_eq!(fit(&changed), expected); +} + +#[test] +fn camera_toe_is_robust_to_shadow_noise() { + let (sensor, clean) = steep_toe_scene(); + let mut noisy = clean.clone(); + for (i, p) in noisy.data.iter_mut().enumerate() { + if luminance_2020(*p) < 0.015 { + // Quantized JPEG levels with bounded noise and occasional compression outliers. + let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004; + *p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003); + if i % 23 == 0 { + *p = [0.014; 3]; + } + } + } + let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "noisy shadow luminance error {error}"); + assert_eq!(look.tone.apply(0.0), 0.0); + assert_eq!(look.matrix, Mat3::IDENTITY); + for i in 1..1000 { + assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0)); + } +} + +#[test] +fn camera_toe_requires_lower_independent_dark_error() { + for holdout_matches_original in [true, false] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 }; + *src = [y; 3]; + // Midtones teach the original slope of 3. Dark training teaches 0.3, + // but the independent dark holdout can agree with either slope. + let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 }; + *dst = [y * slope; 3]; + assert_eq!(colour_pair(*src, *dst), i >= 600); + } + let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap(); + let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); + // Both sample sets start at a multiple of three, so this split matches + // the helper's separate colour and extra-sample indices. + let candidate = fit_tone( + sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from)))) + .collect(), + ) + .unwrap(); + let held: Pairs = sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); + assert_eq!(held.len(), 200); + assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y)))); + assert!(rank_correlation_ties(&held) > 0.99); + let error = |tone: &CameraTone| { + held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::() + }; + assert_ne!(candidate, original); + let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap(); + if holdout_matches_original { + assert!(error(&candidate) > error(&original) + 0.1); + assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve"); + } else { + assert!(error(&candidate) < error(&original) * 0.99); + assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate"); + } + let mut previous = 0.0; + assert_eq!(selected.apply(0.0), 0.0); + for i in 0..10_000 { + let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0)); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + assert_eq!(selected.apply(f32::NAN), 0.0); + assert_eq!(selected.apply(f32::INFINITY), 0.0); + } +} + +#[test] +fn unreliable_dark_pairs_keep_the_original_tone_and_colour() { + let (sensor, reference) = steep_toe_scene(); + let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect(); + let mut excluded = sensor.clone(); + for &i in &dark { + excluded.data[i] = [f32::NAN; 3]; + } + let colour = Some((Mat3::IDENTITY, None)); + let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap(); + for case in 0..6 { + let (mut x, mut y) = (sensor.clone(), reference.clone()); + for (j, &i) in dark.iter().enumerate() { + match case { + 0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal + 1 => x.data[i] = [0.00001; 3], // below the sensor noise floor + 2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs + 3 => x.data[i] = [0.003; 3], // no scene luminance spread + 4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe + 5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise + _ => {} + } + } + let look = fit_pairs_with(&x, &y, colour.clone()).unwrap(); + assert_eq!(look.tone, original.tone, "case {case}"); + assert_eq!(look.matrix, original.matrix, "case {case}"); + assert!(look.hue_sat.is_none()); + } +} + +#[test] +fn camera_jpeg_matches_the_sensor_proxy_size_exactly() { + // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy + // it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy. + let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box); + assert_eq!((sensor.width, sensor.height), (192, 128)); + let jpeg = Rgb32f::new(384, 257); + // what fitting gave before: a pixel short, so every pair was rejected + assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191); + let r = reference_at(&jpeg, &sensor); + assert_eq!((r.width, r.height), (192, 128)); + // and at the single-photo proxy size, where fitting happened to land on the same size + let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box); + let r = reference_at(&Rgb32f::new(192, 129), &sensor); + assert_eq!((r.width, r.height), (sensor.width, sensor.height)); +} + +#[test] +fn linear_arw_gets_a_sensor_proxy_without_demosaicing() { + use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect}; + let mut raw = RawImage { + format: RawFormat::Arw, + width: 32, + height: 32, + cpp: 3, + data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)), + cfa: None, + bits: 16, + black: BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: Rect::new(0, 0, 32, 32), + crop: Rect::new(0, 0, 32, 32), + orientation: Orientation::from_exif(1), + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: true, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: lightcraft_meta::Metadata::default(), + lens_metadata: Default::default(), + }; + let proxy = sensor_proxy(&raw, 2, 384).unwrap(); + assert_eq!((proxy.width, proxy.height), (32, 32)); + assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]); + raw.data = RawData::F32(Vec::new()); + assert!(sensor_proxy(&raw, 2, 384).is_none()); +} +#[test] +fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.05 + (i % 31) as f32 * 0.017; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + } + let original = sensor.clone(); + let fit = fit_pairs(&sensor, &reference).unwrap(); + assert_eq!(sensor.data, original.data); + let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let error: f64 = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.025, "{error}"); + // The colour transform is homogeneous; tone mapping happens only after exposure. + let p = fit.matrix.apply([2.0, 2.0, 2.0]); + assert!(luma(p) > 1.0); + assert!(tone.apply(0.2) < tone.apply(0.4)); +} +#[test] +fn accepts_a_much_better_fit_despite_local_camera_processing() { + // The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12 + // per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback. + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + let mut seed = 0x2545_f491_u32; + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.05 + (i % 31) as f32 * 0.017; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let noise = (seed % 2001) as f32 / 1000.0 - 1.0; + (v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97) + }); + } + assert!(fit_pairs(&sensor, &reference).is_some()); +} + +/// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime +/// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most +/// of the gap, keep neutrals neutral and keep luminance. +#[test] +fn hue_table_follows_a_hue_dependent_camera_rendering() { + let to = to_prophoto(); + let from = to.inverse().unwrap(); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + let mut lime = Vec::new(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.05 + (i % 29) as f32 * 0.02; + // mostly greys and mild colours, plus a patch of saturated yellow-green + *src = if i % 7 == 0 { [ev * 0.75, ev, ev * 0.2] } else { [ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)] }; + let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap(); + // the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation + let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0); + let mut p = to.apply(src.map(f64::from)).map(|v| v as f32); + let table = HsvTable { + hue_divisions: 1, + sat_divisions: 2, + val_divisions: 1, + data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]], + srgb_value: false, + }; + p = table.apply(p); + let p = from.apply(p.map(f64::from)).map(|v| v as f32); + let scale = luminance_2020(*src) / luminance_2020(p); + let p = p.map(|v| v * scale); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + if i % 7 == 0 { + lime.push(i); + } + } + let fit = fit_pairs(&sensor, &reference).unwrap(); + let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted"); + let hue_sat = HueSat::new(table).unwrap(); + let hue_error = |with_table: bool| { + lime.iter() + .map(|&i| { + let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32); + let p = if with_table { hue_sat.apply(p) } else { p }; + let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap(); + let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap(); + ((h - t + 180.0).rem_euclid(360.0) - 180.0).abs() + }) + .sum::() + / lime.len() as f64 + }; + let (before, after) = (hue_error(false), hue_error(true)); + assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°"); + // neutrals pass through and luminance is kept + let grey = [0.3, 0.3, 0.3]; + assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey)); + let green = [0.2, 0.4, 0.05]; + assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5); +} + +/// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve), +/// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright +/// warm-tinted rock renders white instead of cream. +#[test] +fn chroma_curve_follows_highlight_bleaching() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let camera_chroma = |o: f32| 1.3 - 1.1 * o; + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.02 + (i % 37) as f32 * 0.03; + *src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + let o = 1.0 - (-2.5 * y).exp(); + let k = camera_chroma(o); + *dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999)); + } + let fit = fit_pairs(&sensor, &reference).unwrap(); + // relative to the matrix, which already carries the average colourfulness + let chroma = fit.tone.chroma(); + assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}"); + // and the rendered highlights land on the camera's, far closer than without the curve + let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0); + let highlight_error = |map: &ToneMap| -> f64 { + sensor + .data + .iter() + .zip(&reference.data) + .filter(|(_, y)| luminance_2020(**y) > 0.7) + .map(|(x, y)| { + let p = displayed(fit.matrix.apply(x.map(f64::from)), map); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() + }) + .sum() + }; + let (a, b) = (highlight_error(&without), highlight_error(&with)); + assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}"); +} + +/// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that +/// colour model (a camera profile) keeps it, fitting only its own tone. +#[test] +fn profile_colour_is_pooled_and_then_used_as_given() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let photo = |seed: usize, strength: f32| { + let mut sensor = Rgb32f::new(64, 48); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let i = i + seed * 7; + let ev = 0.04 + (i % 23) as f32 * 0.02; + *src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + // each photo has its own tone (DRO, picture style) + *dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y); + } + (sensor, reference) + }; + let mut pool = Vec::new(); + for seed in 0..4 { + let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); + } + let (matrix, _) = fit_profile(&pool).unwrap(); + // the camera's colours (luminance-normalised: the tone curve sets brightness) + for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] { + let (a, b) = (matrix.apply(x), known.apply(x)); + let (la, lb) = (luma(a), luma(b)); + assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}"); + } + let (sensor, reference) = photo(9, 3.5); + let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); + assert_eq!(look.matrix, matrix); + assert!(look.hue_sat.is_none()); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt()); +} + +#[test] +fn profile_fits_tone_without_learning_colour_from_a_dull_scene() { + let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]); + let mut sensor = Rgb32f::new(64, 48); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.02 + (i % 997) as f32 * 0.00045; 3]; + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + } + assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix"); + let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); + assert_eq!(look.matrix, matrix); + reference.map_in_place(|p| [luminance_2020(p); 3]); + assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected"); +} + +#[test] +fn rejected_profile_preserves_a_usable_per_photo_fit() { + let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + let mut seed = 0x2545_f491_u32; + for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { + *src = std::array::from_fn(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + 0.05 + (seed % 1000) as f32 * 0.0003 + }); + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999)); + } + let per_photo = fit_pairs(&sensor, &reference).unwrap(); + let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap(); + assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit"); + assert_eq!(look.tone, per_photo.tone); +} + +#[test] +fn hue_sat_passes_black_and_non_finite_pixels_through() { + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]); + let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]); + assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1); + assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); +} + +#[test] +fn hue_sat_never_turns_near_black_noise_white() { + // a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0 + }; + let mut worst = 0.0f32; + for _ in 0..200_000 { + let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()]; + if luminance_2020(rgb) <= 0.0 { + continue; + } + let out = hue_sat.apply(rgb); + let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs())); + worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale); + } + assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); +} + +/// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, +/// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. +#[test] +fn hue_sat_lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; + let fitted = (0..72 * 5 * 5) + .map(|_| { + let r = next(); + [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] + }) + .collect(); + let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; + let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; + let mut colours = Vec::new(); + for a in special { + for b in special { + colours.extend(special.map(|c| [a, b, c])); + } + } + for _ in 0..60_000 { + let r = next(); + let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; + colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); + } + for table in [fitted, saturating] { + let hue_sat = HueSat::new(&table).unwrap(); + for chunk in colours.chunks(16) { + let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } +} + +#[test] +fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { + assert_eq!(restore_weight(0.0), 0.0); + assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0); + assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0); + assert_eq!(restore_weight(1.0), 1.0); + assert_eq!(restore_weight(f32::NAN), 0.0); + // monotonic and without a step: neighbouring shares 0.001 apart differ by little + let mut last = 0.0f32; + for i in 0..=400 { + let w = restore_weight(i as f32 * 0.001); + assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001); + last = w; + } +} + +#[test] +fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() { + // the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; + for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] { + let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb))); + let (before, after) = (luminance_2020(rgb), luminance_2020(out)); + assert!(after >= MIN_LUMINANCE_KEPT * before); + assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after)); + } +} + +fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { + let area = lightcraft_raw::Rect::new(0, 0, 2, 2); + RawImage { + format, + width: 2, + height: 2, + cpp: 1, + data: lightcraft_raw::RawData::U16(vec![0; 4]), + cfa: None, + bits: 14, + black: Default::default(), + white: vec![16383.0], + active_area: area, + crop: area, + orientation: Default::default(), + color, + wb_multipliers: Some([2.0, 1.0, 1.5]), + linearized: false, + pixel_aspect: 1.0, + opcodes: Default::default(), + metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + lens_metadata: Default::default(), + } +} + +/// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain +/// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. +#[test] +fn lens_geometry_reads_the_first_rectilinear_warp() { + use lightcraft_raw::Opcode; + let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { + planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + center: c, + }; + assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); + let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); + assert!(lens_geometry(&[]).is_none()); + assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); + assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); + let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + p[0] = 1.0; + p[18] = 1e6; + let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; + assert!(lens_geometry(&[chain, single]).is_none()); +} + +/// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp +/// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside +/// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. +#[test] +fn the_sensor_proxy_follows_the_cameras_lens_warp() { + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); + raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); + raw.crop = raw.active_area; + // a 1/4 proxy whose pixels hold their own coordinates + let mut img = Rgb32f::new(16, 12); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; + } + let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; + let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); + assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); + // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor + let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); + assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); + let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); + assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); + assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); +} + +/// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected +/// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every +/// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. +#[test] +fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(entries) = std::fs::read_dir(&dir) else { + eprintln!("skip: no corpus at {}", dir.display()); + return; + }; + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + let (mut checked, mut gain) = (0, 0.0); + for path in paths { + let Ok(bytes) = std::fs::read(&path) else { continue }; + let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; + let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = + (proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) + else { + continue; + }; + let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); + eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); + // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) + assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); + gain += after - before; + checked += 1; + } + eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); + assert!(checked == 0 || gain > 0.0); +} + +/// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit +/// is tried. +#[test] +fn a_files_own_matrices_come_first() { + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| panic!("no JPEG fit for a raw with its own matrices"), + |_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"), + |_, _| panic!("no dark tone fit for a raw with its own matrices"), + ); + assert_eq!(raw.color, color); + assert!(!t.matrix_is_fallback && look.is_none()); +} + +/// Steps 2 and 3 before step 4: an accepted JPEG fit (with or without a camera profile) is kept for a model that +/// has spectral matrices, and the spectral matrices are not written. Photos that had a fitted look before the +/// spectral table render exactly as they did. +#[test] +fn an_accepted_jpeg_fit_comes_before_the_spectral_matrices() { + for (make, model) in [("SONY", "ILCE-7M4"), ("Canon", "Canon EOS 600D")] { + assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}"); + let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default()); + let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()); + let (xy, t, look) = starting_colour_with( + &mut raw, + |raw, t| { + assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); + (Some(fitted.clone()), false) + }, + |_, _| panic!("no black-and-white JPEG fit after an accepted fit"), + |_, _| panic!("no dark tone fit after an accepted fit"), + ); + assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); + assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); + assert!(t.matrix_is_fallback, "{model}"); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + } +} + +/// Step 4: when the JPEG fit fails (here: no camera JPEG at all), a model with a spectral row takes its matrices, +/// whether or not it has a camera profile (the ILCE-7M4 has a bundled one). +#[test] +fn a_failed_jpeg_fit_falls_back_to_the_spectral_matrices() { + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); + let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); + assert!(look.is_none()); + assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + + let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (xy, t, look) = + starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None); + assert!(look.is_none()); + assert_eq!(raw.color.illuminant, [17, 21]); + assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + // white-balanced white stays white + let w = t.matrix.apply([1.0; 3]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); +} + +/// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the +/// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise +/// (issue #577). The black-and-white fit sees that transform. +#[test] +fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() { + let toned = CameraLook { + matrix: Mat3::IDENTITY, + tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone, + hue_sat: None, + }; + let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (xy, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |raw, t| { + assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in"); + Some(toned.clone()) + }, + |_, _| panic!("an accepted monochrome look keeps its tone"), + ); + assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top"); + assert!(!t.matrix_is_fallback); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |_, t| { + assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour"); + Some(toned.clone()) + }, + |_, _| panic!("an accepted monochrome look keeps its tone"), + ); + assert!(look.is_some() && t.matrix_is_fallback); + assert_eq!(raw.color, lightcraft_raw::ColorData::default()); +} + +/// Step 5: a camera outside the table whose fit fails keeps the neutral fallback. +#[test] +fn a_camera_outside_the_table_keeps_the_fallback() { + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); + assert!(look.is_none() && t.matrix_is_fallback); + assert_eq!(raw.color, lightcraft_raw::ColorData::default()); + // without a model there is no table row + let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); + raw.metadata.model = None; + assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback); +} + +/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is +/// accepted keeps it and its colour data; one whose fit fails takes its spectral matrices when its model has +/// them; a DNG keeps its own matrices. White balance stays relative to the as-shot look for every raw without +/// matrices of its own. +#[test] +fn corpus_colour_precedence() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + lightcraft_testkit::skip!(Corpus, "{name} absent"); + continue; + }; + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + let lens = lens_geometry(&raw.opcodes.list3); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let own = lightcraft_raw::color::has_matrix(&raw.color); + let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); + let measured = lightcraft_raw::spectral::find(make, model).is_some(); + let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); + let before = raw.color.clone(); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None); + let spectral = !own && fitted.is_none() && measured; + eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); + assert_eq!(raw.color != before, spectral, "{name}"); + assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); + // white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin, + // through the spectral matrices or its presets, whichever look it got (issue #730) + assert_eq!(info.relative_wb, !own && !covered, "{name}"); + assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); + if covered && !own { + assert!( + (2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0, + "{name}: {info:?}" + ); + } + // the same as the catalog decides from the probe (format and camera) + let probe = crate::files::probe_bytes(name, &bytes).unwrap(); + let mut p = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(1), + lightcraft_catalog::Source::File { path: name.into() }, + name, + &probe.format, + 1, + 1, + "", + ); + p.kind = probe.kind; + p.meta = probe.meta.clone(); + assert_eq!(p.relative_wb(), info.relative_wb, "{name}"); + assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); + } +} + +#[test] +fn supported_raws_get_a_file_local_look() { + // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) + let local = [ + RawFormat::Arw, + RawFormat::Nef, + RawFormat::Nrw, + RawFormat::Rw2, + RawFormat::Raf, + RawFormat::Cr3, + RawFormat::Cr2, + RawFormat::Pef, + RawFormat::Srw, + RawFormat::Orf, + RawFormat::Mrw, + RawFormat::Erf, + ]; + assert!(local.into_iter().all(file_local_look)); + // DNG carries its own colour model + assert!(!file_local_look(RawFormat::Dng)); +} + +#[test] +fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() { + let (w, h) = (192, 128); + let mut sensor = Rgb32f::new(w, h); + for (i, pixel) in sensor.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos(); + *pixel = [value; 3]; + } + let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] }; + let mut reference = Rgb32f::new(w, h); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7)); + } + let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone"); + assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}"); + assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}"); + assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}"); + assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged"); + reference.data.fill([0.2; 3]); + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing"); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + *pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3]; + } + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing"); +} + +/// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus +/// download or an unfinished codec cannot make this test claim a successful RAW calibration. +#[test] +fn corpus_cr3_gets_a_framing_aligned_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/cr3-canon-r100-raw.cr3"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let raw = match lightcraft_raw::decode(&bytes) { + Ok(raw) => raw, + Err(lightcraft_raw::RawError::Unsupported(why)) => { + lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); + return; + } + Err(error) => panic!("CR3 corpus failed to decode: {error}"), + }; + assert_eq!(raw.format, RawFormat::Cr3); + assert_eq!(raw.info().developed_size(), (6000, 4000)); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, None).0; + eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); + assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.raw && info.relative_wb); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0)); + assert_eq!(info.camera_tone.is_some(), look.is_some()); +} + +/// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model` +/// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the +/// photo's own fit (the fallback when a profile is rejected) would not. +fn corpus_uses_the_bundled_profile(file: &str, model: &str) { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw") + .join(file); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + } + Err(e) => panic!("{}: {e}", path.display()), + }; + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in"); + let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); + let used = crate::camera_profiles::get(model).expect("a profile for the model"); + assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(raw.metadata.model.as_deref(), Some(model)); + // as the app does (`files.rs`): the camera's own lens warp pairs the sensor with its JPEG (Nikon's in-file + // distortion for the Z 6II and Z 8), and the lens corrections leave the opcodes before the proxy is binned + let lens = lens_geometry(&raw.opcodes.list3); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, lens.as_ref()).0.expect("fit accepted"); + let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); + for (row, want) in look.matrix.0.iter().zip(expected.0) { + for (got, want) in row.iter().zip(want) { + assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0); + } + } + assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); +} + +#[test] +fn corpus_a7cr_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR"); +} + +#[test] +fn corpus_a99m2_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2"); +} + +/// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. +#[test] +fn corpus_z6ii_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); +} + +/// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. +#[test] +fn corpus_z8_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); +} + +/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white +/// balance is relative to the as-shot look. +#[test] +fn corpus_nef_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-d7500-lossless14.nef"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); +} + +/// The public ILCE-7CR samples (61 MP; skipped without the corpus): lossless compressed L and M and compressed +/// ARW2 all get a look fitted to their own JPEG, not the neutral fallback. +#[test] +fn corpus_a7cr_codings_get_a_camera_look() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for name in ["arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw", "arw-sony-a7cr-compressed.arw"] { + let bytes = match std::fs::read(dir.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lightcraft_testkit::skip!(Corpus, "{name} absent"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!((img.width, img.height), (400, 267), "{name}: framed in the default crop"); + assert!(info.camera_tone.is_some(), "{name}: no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0, "{name}"); + } +} + +/// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3 +/// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it. +#[test] +fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio"); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); +} + +/// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is +/// distortion-corrected, and on its glass façade the fit on all pixels misses the gate +/// (held-out RMS 0.113): it used to open grey with the neutral fallback. +#[test] +fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); +} + +/// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same +/// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation +/// table and its tone curve; without a look, those of the camera transform. +#[test] +fn dng_colour_renders_like_the_raws_starting_colour() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37)); + assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb); + let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); + for look in [Some(&look), None] { + let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap(); + let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); + for i in 0..3 { + assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); + } + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + for c in samples { + let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c)); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}"); + } + assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone())); + let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve); + match look { + Some(look) => { + let tone = tone.expect("the look's tone curve"); + let worst = + (0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max); + // (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here) + assert!(worst < 4e-3, "{worst}"); + } + None => assert!(tone.is_none()), + } + } +} + +/// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white +/// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their +/// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8. +#[test] +fn dng_colour_declares_the_white_balance_the_source_shows() { + use crate::files::DngWhite; + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers}; + let tags = ColorData::default(); + let as_shot = [2.5039f32, 1.0, 1.5078]; + let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot))); + let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]); + let tint = look.matrix.apply([1.0; 3]); + assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}"); + let renders_as_shot = |dng: &ColorData| { + let r = camera_transform_of(dng, as_shot_white_of(dng, None)); + assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb); + let m = look.matrix.mul(&t.matrix); + for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] { + let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()); + let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2()); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}"); + } + }; + // a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0) + for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] { + let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}"); + renders_as_shot(&dng); + } + // a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera"); + let mut model = tags.clone(); + assert!(camera.fill(&mut model)); + let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap(); + let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}"); + for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] { + let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}"); + } + renders_as_shot(&dng); +} + +/// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without +/// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking +/// it from the multipliers again leaves the merged DNG rendering as the tags do. +#[test] +fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { + let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); + raw.wb_multipliers = Some(multipliers); + let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); + let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); + // the reciprocal of the multipliers, up to the scale a neutral is given in + let scale = neutral[1] * multipliers[1] as f64; + for i in 0..3 { + assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); + } + let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); + merged.set_as_shot_multipliers(multipliers); + let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; + let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); + for i in 0..3 { + assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); + } + for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { + assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); + } + assert_eq!(a.baseline_exposure, b.baseline_exposure); + } +} + +/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. +fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { + CameraLook { + matrix: Mat3([[1.6, -0.4, -0.2], [-0.2, 1.4, -0.2], [0.0, -0.3, 1.3]]), + tone: CameraTone::new(xs.map(|x| [x, f(x)])).unwrap().with_chroma([1.3, 1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.3]).unwrap(), + hue_sat: Some(HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.1, 1.0]; 8], srgb_value: false }), + } +} + +/// `fixed` (from `fitted`) is `min(fitted, Sony's DRO-off curve)` at every knot but the +/// second-last, which may stay higher (at most the fitted curve) to keep the shoulder past the +/// last knot below the fitted one; nowhere brighter than `fitted`, colour unchanged. +fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) { + let dro_off = sony_dro_off_tone().unwrap(); + assert_eq!((fixed.matrix, &fixed.hue_sat, fixed.tone.chroma()), (fitted.matrix, &fitted.hue_sat, fitted.tone.chroma()), "{what}"); + for (i, (k, own)) in fixed.tone.knots().iter().zip(fitted.tone.knots()).enumerate() { + let lowered = own[1].min(dro_off.apply(own[0])); + assert_eq!(k[0], own[0], "{what}: knot {i}"); + if i == 30 { + assert!(k[1] >= lowered && k[1] <= own[1], "{what}: knot {i} {} not in {lowered}..={}", k[1], own[1]); + } else { + assert!((k[1] - lowered).abs() < 1e-6, "{what}: knot {i} {} vs {lowered}", k[1]); + } + } + assert!(at_most(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve somewhere"); + // the same check spelled out, from deep shadows to far past the last knot (the shoulder) + for i in 0..=2400 { + let x = 0.001 * 1.005f32.powi(i); + assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x)); + } +} + +/// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A +/// look fitted to such a JPEG keeps its colour (matrix, hue/saturation table, chroma curve) and +/// its curve is lowered to Sony's without DRO wherever that is darker, never raised. +#[test] +fn a_dro_look_keeps_its_colour_and_is_lowered_to_the_dro_off_tone() { + let dro_off = sony_dro_off_tone().expect("the built-in curve is valid"); + // the curve itself: black stays black, increasing, mid-grey where cameras put it (L* 50–75) + assert_eq!(dro_off.apply(0.0), 0.0); + let mut previous = 0.0; + for i in 1..200 { + let y = dro_off.apply(i as f32 * 0.005); + assert!(y > previous && y < 1.0, "{i}: {y}"); + previous = y; + } + assert!((0.18..0.48).contains(&dro_off.apply(0.18)), "{}", dro_off.apply(0.18)); + let xs: [f32; 32] = std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)); + // a JPEG with lifted shadows: the curve comes down to Sony's, colour and chroma curve stay + let lifted = dro_test_look(&xs, &|x| dro_off.apply(x).powf(0.8)); + let fixed = with_dro_off_tone(lifted.clone()); + assert_lowered(&lifted, &fixed, "lifted"); + assert!(fixed.tone.apply(0.02) < 0.85 * lifted.tone.apply(0.02)); + // a camera that maps raw values darker than Sony's curve keeps its own curve exactly + let darker = dro_test_look(&xs, &|x| 0.7 * dro_off.apply(x)); + assert_eq!(with_dro_off_tone(darker.clone()).tone, darker.tone); + // crossing curves: the lower of the two at the knots, still a valid increasing curve + let crossing = dro_test_look(&xs, &|x| (dro_off.apply(x) * (0.6 + x)).min(0.95)); + assert_lowered(&crossing, &with_dro_off_tone(crossing.clone()), "crossing"); +} + +/// Past its last knot a camera curve continues as a shoulder whose rate comes from its last two +/// knots. Lowering the second-last knot more than the last steepened that shoulder: the curve +/// fitted to the CC0 NEX-5T sample (raw.pixls.us, DRO Auto) went from 0.740 to 0.843 at scene +/// luminance 0.63, brighter than before. The lowered curve must stay at or below the fitted one +/// through the shoulder, on that curve and on many others. +#[test] +fn the_lowered_curve_stays_below_the_fitted_one_past_its_last_knot() { + let nex5t: [[f32; 2]; 32] = [ + [0.014948029, 0.017424058], + [0.018395446, 0.023981506], + [0.021539066, 0.030581286], + [0.024555191, 0.0374646], + [0.027602654, 0.044408206], + [0.030193308, 0.049894042], + [0.03261828, 0.055907387], + [0.034544908, 0.06156683], + [0.03669959, 0.06814718], + [0.039161213, 0.075263545], + [0.04173663, 0.0830011], + [0.044166446, 0.088166036], + [0.04682434, 0.095398724], + [0.049544845, 0.1027153], + [0.05235826, 0.11173598], + [0.05497513, 0.119017586], + [0.057503678, 0.12616614], + [0.059836045, 0.13521175], + [0.062677935, 0.14601946], + [0.06599234, 0.15573128], + [0.069809504, 0.16883339], + [0.0755404, 0.1854325], + [0.08250754, 0.20684941], + [0.089204684, 0.23466235], + [0.09650368, 0.25805998], + [0.102161564, 0.27849936], + [0.107812546, 0.29706743], + [0.11410169, 0.31379074], + [0.12000221, 0.33262977], + [0.12776275, 0.35407156], + [0.1387929, 0.38362882], + [0.19492775, 0.43935108], + ]; + let fitted = CameraLook { tone: CameraTone::new(nex5t).unwrap(), ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; + let fixed = with_dro_off_tone(fitted.clone()); + assert_lowered(&fitted, &fixed, "NEX-5T"); + assert!((fitted.tone.apply(0.63) - 0.7405).abs() < 1e-3 && fixed.tone.apply(0.63) <= fitted.tone.apply(0.63)); + assert_ne!(fixed.tone, fitted.tone, "the lifted mid-tones still come down"); + // curves that end at all kinds of scene luminances, slopes and heights relative to Sony's + let dro_off = sony_dro_off_tone().unwrap(); + let mut seed = 0x9e37_79b9_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 65536.0 + }; + for case in 0..300 { + let (start, span) = (0.002 + 0.03 * next(), 1.05 + 0.15 * next()); + let xs: [f32; 32] = std::array::from_fn(|i| start * span.powi(i as i32)); + let (lift, gain, top) = (0.5 + 0.6 * next(), 0.6 + 0.8 * next(), next()); + // brighter or darker than Sony's, with a last knot sometimes lowered less than the one before + let f = |x: f32| (gain * dro_off.apply(x).powf(lift) + top * 0.2 * (x / xs[31]).powi(8)).min(0.995); + let mut ys = xs.map(f); + for i in 1..32 { + ys[i] = ys[i].max(ys[i - 1]); + } + let Some(tone) = CameraTone::new(std::array::from_fn(|i| [xs[i], ys[i]])) else { continue }; + let fitted = CameraLook { tone, ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; + assert_lowered(&fitted, &with_dro_off_tone(fitted.clone()), &format!("case {case}")); + } +} + +/// Issue #244 (skipped without the corpus): the public ILCE-7RM2 sample (Standard creative +/// style) was shot with DRO Auto, so its curve comes down to Sony's without DRO in the shadows the +/// camera lifted; the ILCE-7M4 sample (DRO Auto, Vivid) has deeper shadows than that curve and +/// keeps them, only its brighter highlights come down. Both keep the colour fitted to their +/// JPEGs. The ILCE-7M3 sample (DRO off) keeps its own fit. +#[test] +fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let samples = [ + ("arw-sony-a7rm2-12bit-uncompressed.arw", true, [0.01, 0.05, 0.1]), + ("arw-sony-a7m4-14bit.arw", true, [0.3, 0.4, 0.5]), + ("arw-sony-a7m3-compressed.arw", false, [0.0; 3]), + ]; + for (name, dro, lowered) in samples { + let path = dir.join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + continue; + } + Err(e) => panic!("{}: {e}", path.display()), + }; + assert_eq!(lightcraft_raw::embedded_preview_dynamic_range_optimized(&bytes), Some(dro), "{name}"); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); + // the look fitted to the JPEG, as before this change + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap(); + let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); + let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); + let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); + if dro { + assert_lowered(&fitted, &look, name); + for x in lowered { + assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x)); + } + } else { + assert_eq!((look.matrix, &look.hue_sat, look.tone), (fitted.matrix, &fitted.hue_sat, fitted.tone), "{name}"); + } + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(info.camera_tone, Some(look.tone), "{name}"); + } +} + +/// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a +/// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree +/// everywhere away from the edges, and the look must still be found there. +#[test] +fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h, block, shift) = (96, 64, 8, 2); + // Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour. + let scene = |x: usize, y: usize| -> [f32; 3] { + let (bx, by) = (x / block, y / block); + let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 }; + [ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)] + }; + let camera = |p: [f32; 3]| { + let q = known.apply_f32(p); + let y = luminance_2020(q); + q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x, y); + // The JPEG shows the scene `shift` pixels to the right of where the raw has it. + reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y)); + } + } + let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges"); + // The look itself is right: on the aligned scene it reproduces the camera. + let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let (mut error, mut n) = (0.0, 0); + for y in 0..h { + for x in 0..w { + let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone); + let t = camera(scene(x, y)); + error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::(); + n += 3; + } + } + assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt()); + // Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well. + reference.data.fill([0.2; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); +} + +/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes. +/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away +/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0 +/// corpus files. +#[test] +fn corpus_raf_colour_and_white_balance() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + // (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those + // matrices; the X-T2 and X-T20 have none and stay relative) + for (name, accepted, kelvin) in + [("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)] + { + let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); + let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); + assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); + assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); + assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}"); + assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}"); + if kelvin { + assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp); + } else { + assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + } + } +} + +/// Sony ARWs without measured matrices read Kelvin from their white-balance presets +/// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and +/// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its +/// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files. +#[test] +fn corpus_arw_white_balance_in_kelvin_through_the_presets() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); + assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); + assert!((info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, "{name}: {} {}", info.as_shot_temp, info.as_shot_tint); + let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); + } +} + +#[test] +fn rejects_monochrome_invalid_and_unrelated_previews() { + let mut sensor = Rgb32f::new(32, 32); + let mut reference = sensor.clone(); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1]; + } + reference.data.fill([0.2; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + reference.data.fill([f32::NAN; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01]; + *dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; + } + assert!(fit_pairs(&sensor, &reference).is_none()); + sensor.data.fill([0.1, 0.15, 0.12]); + assert!(fit_pairs(&sensor, &reference).is_none()); + reference.data.truncate(8); + assert!(fit_pairs(&sensor, &reference).is_none()); +} + +/// A busy scene whose JPEG is the camera curve of the scene two proxy pixels to the right (a +/// lens-corrected preview): the fitted curve follows the camera's and has no flat steps. +#[test] +fn tone_fit_has_no_steps_on_a_misregistered_jpeg() { + let camera = |x: f64| 0.6 * x.powf(0.55) / (1.0 + 0.2 * x); + let (w, h) = (96usize, 64usize); + let mut seed = 0x2545_f491_u32; + let mut scene = Vec::with_capacity(w * h); + for _ in 0..w * h { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + scene.push(0.01 * (5.0 * f64::from(seed >> 8) / f64::from(1u32 << 24)).exp()); + } + let pairs: Vec<(f64, f64)> = + (0..h).flat_map(|y| (0..w - 2).map(move |x| (y * w + x, y * w + x + 2))).map(|(i, j)| (scene[i], camera(scene[j]))).collect(); + let curve = fit_tone(pairs).unwrap(); + let mut sorted = scene.clone(); + sorted.sort_by(f64::total_cmp); + let (lo, hi) = (sorted[sorted.len() / 50], sorted[sorted.len() * 49 / 50]); + let mut x = lo; + while x < hi { + let (a, b) = (curve.apply(x as f32), curve.apply((x * 1.02) as f32)); + assert!(b > a, "flat step at {x}: {a} -> {b}"); + assert!((f64::from(a) / camera(x) - 1.0).abs() < 0.05, "at {x}: {a} vs {}", camera(x)); + x *= 1.02; + } +} + +/// A synthetic photo whose JPEG is the camera look of the scene scaled per pixel by noise of +/// `spread` (a pseudo-random, registration-free stand-in for local tone mapping): the per-bin +/// median recovers the curve, while the JPEG's broadened distribution stretches the quantile fit. +fn noisy_look(spread: f32) -> (Rgb32f, Rgb32f) { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + let mut seed = 0x9e37_79b9_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 32768.0 - 1.0 + }; + for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { + let ev = 0.05 + (next() + 1.0) * 0.2; + *src = [ev * (0.8 + (next() + 1.0) * 0.1), ev, ev * (0.8 + (next() + 1.0) * 0.1)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + let scale = (1.0 - (-2.5 * y).exp()) / y * (1.0 + spread * next()); + *dst = p.map(|v| v * scale); + } + (sensor, reference) +} + +/// A camera rendering with constant colourfulness (no highlight bleaching) of a colourful scene +/// under a bright neutral sky, whose sensor pixels are clipped: one channel held at the clip +/// level turns the white sky magenta in the raw while the camera JPEG keeps it white. Returns +/// the sensor proxy, the JPEG proxy and the clip mask. +fn clipped_sky_scene() -> (Rgb32f, Rgb32f, Vec) { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + let mut clipped = vec![false; w * h]; + let mut seed = 0x2545_f491_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 65536.0 + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + let o = 1.0 - (-2.5 * y).exp(); + p.map(|v| (v * o / y).clamp(0.0, 0.97)) + }; + // the sky: a quarter of the frame, white in the scene and in the JPEG + let neutral = known.inverse().unwrap().apply_f32([0.32; 3]); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let scene = if i < w * h / 4 { + clipped[i] = true; + let sky = neutral.map(|v| v * (0.8 + 0.4 * next())); + *dst = camera(known.apply_f32(sky)); + // the green sample clips, red and blue (after white balance) go on rising + *src = [sky[0] * 1.35, sky[1] * 0.85, sky[2] * 1.4]; + continue; + } else { + // colourful foliage, leaves, bark and bright flowers from dark to light + let ev = 0.02 + next() * 0.4; + [ev * (0.6 + next() * 0.8), ev, ev * (0.5 + next() * 0.8)] + }; + *src = scene; + *dst = camera(known.apply_f32(scene)); + } + (sensor, reference, clipped) +} + +/// Mean colourfulness (distance from neutral of luminance-normalised RGB) of the unclipped +/// pixels rendered with `look`, over that of the camera JPEG, per display-luminance band. +fn colourfulness_ratio(look: &CameraLook, sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], band: std::ops::Range) -> f64 { + let chroma = |p: [f64; 3]| { + let y = luma(p); + p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt() + }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let (mut rendered, mut camera) = (0.0, 0.0); + for ((x, y), c) in sensor.data.iter().zip(&reference.data).zip(clipped) { + let y = y.map(f64::from); + if *c || !band.contains(&luma(y)) { + continue; + } + rendered += chroma(displayed(look.matrix.apply(x.map(f64::from)), &tone)); + camera += chroma(y); + } + rendered / camera +} + +/// Clipped sensor pixels are left out of the look's training pairs: a clipped white sky no longer +/// dulls the colour matrix or makes the chroma curve remove colour at the sky's brightness. +#[test] +fn clipped_sky_does_not_dull_the_look() { + let (sensor, reference, clipped) = clipped_sky_scene(); + let old = fit_pairs(&sensor, &reference).expect("the earlier fit accepts the look"); + let new = fit_look(&sensor, &reference, &clipped, None).expect("look without the clipped pixels"); + let (mid, bright) = (0.1..0.35, 0.35..0.75); + let old_ratio = ( + colourfulness_ratio(&old, &sensor, &reference, &clipped, mid.clone()), + colourfulness_ratio(&old, &sensor, &reference, &clipped, bright.clone()), + ); + let new_ratio = (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright)); + // with the clipped sky in the pairs, the bright colours lose much of their colour + assert!(old_ratio.1 < 0.8, "earlier fit: colourfulness vs camera {old_ratio:?}, chroma {:?}", old.tone.chroma()); + // without it, the render keeps the camera's colourfulness at every brightness + for r in [new_ratio.0, new_ratio.1] { + assert!((0.9..1.1).contains(&r), "colourfulness vs camera {new_ratio:?}, chroma {:?}", new.tone.chroma()); + } + assert!(new.tone.chroma().iter().all(|k| *k > 0.85), "no colour cut: {:?}", new.tone.chroma()); +} + +/// When the accepted attempt misses the gates without the clipped pixels (here: too few pixels +/// left), the look fitted on all pixels is kept unchanged. +#[test] +fn clipped_fit_falls_back_to_all_pixels() { + let (sensor, reference, _) = clipped_sky_scene(); + let all = fit_pairs(&sensor, &reference).unwrap(); + // nearly everything clipped: too few pairs remain, the earlier look is used unchanged + let mut clipped = vec![true; sensor.data.len()]; + clipped[..100].iter_mut().for_each(|c| *c = false); + let fallback = fit_look(&sensor, &reference, &clipped, None).expect("the fallback keeps the look"); + assert_eq!((fallback.matrix.0, fallback.tone), (all.matrix.0, all.tone)); + // nothing clipped: the same look as before, exactly + let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap(); + assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); + // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap(); + assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); + assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); +} + +/// Quantile matching is tried first; the conditional-median fit is the fallback and is used +/// only where the quantile look misses the acceptance gates, so no photo loses its look. +#[test] +fn tone_fit_order_quantile_first_conditional_median_as_fallback() { + // Clean data: both fits pass, the default order uses the quantile curve (increasing). + let (sensor, reference) = noisy_look(0.0); + let default = fit_pairs(&sensor, &reference).expect("clean look"); + let quantile = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).expect("quantile passes"); + assert_eq!(default.tone, quantile.tone); + // Noisy data: the quantile fit misses the gates, the conditional median still passes. + let (sensor, reference) = noisy_look(0.33); + assert!(fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).is_none(), "quantile alone misses the gates"); + let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).expect("conditional median passes"); + let default = fit_pairs(&sensor, &reference).expect("the fallback keeps the look"); + assert_eq!(default.tone, conditional.tone); + // The order matters only through the first pass; with the fits swapped the conditional one wins even on clean data. + let (sensor, reference) = noisy_look(0.0); + let swapped = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian, ToneFit::Quantile]).unwrap(); + let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap(); + assert_eq!(swapped.tone, conditional.tone); +} + +/// The camera look of `scene` (the known matrix of these tests, then a bright camera curve). +fn bright_camera(p: [f32; 3]) -> [f32; 3] { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let q = known.apply_f32(p); + let y = luminance_2020(q); + if y <= 0.0 { [0.0; 3] } else { q.map(|v| v * (1.0 - (-4.0 * y).exp()) / y) } +} + +/// Mean squared error (all channels, display-linear) of `render` against `target` over `scene`. +fn mean_error(scene: &Rgb32f, target: impl Fn([f32; 3]) -> [f32; 3], render: impl Fn([f64; 3]) -> [f64; 3]) -> f64 { + let error: f64 = scene + .data + .iter() + .map(|x| { + let (p, t) = (render(x.map(f64::from)), target(*x)); + (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::() + }) + .sum(); + error / (3 * scene.data.len()) as f64 +} + +/// A colourful scene with fine detail everywhere (foliage) whose camera JPEG is lens-corrected: +/// barrel distortion moves the picture by up to about 4 proxy pixels toward the corners, so the +/// detail no longer pairs with itself and no global look passes the gates, on all pixels or +/// away from edges. The partial look still follows the camera's brightness, colourfulness and +/// hues: on the registered scene it is far closer to the camera than the neutral fallback, +/// which renders the scene dark and grey. +#[test] +fn partial_look_when_a_lens_corrected_jpeg_misses_the_gates() { + let (w, h) = (96usize, 64usize); + let scene = |u: f32, v: f32| -> [f32; 3] { + let base = 0.1 * (1.0 + 0.6 * (0.05 * u + 0.04 * v).sin()); + let detail = 1.0 + 0.8 * (1.7 * u).sin() * (1.3 * v).sin(); + let y = base * detail; + [y * (0.6 + 0.5 * (0.07 * u).sin().abs()), y, y * (0.5 + 0.6 * (0.09 * v + 0.03 * u).cos().abs())] + }; + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f32 - cx, y as f32 - cy); + let k = 1.0 + 0.08 * (dx * dx + dy * dy) / (cx * cx); + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = bright_camera(scene(cx + dx * k, cy + dy * k)); + } + } + assert!(fit_pairs(&sensor, &reference).is_none(), "the full look misses the gates"); + let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look instead of the neutral fallback"); + assert!(look.hue_sat.is_none()); + assert_ne!(look.matrix, Mat3::IDENTITY, "a colourful scene gets the damped matrix"); + // on the registered scene, against what the camera renders + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let partial = mean_error(&sensor, bright_camera, |x| displayed(look.matrix.apply(x), &tone)); + let fallback = mean_error(&sensor, bright_camera, |x| displayed(x, &neutral)); + assert!(partial < fallback * 0.25, "partial {partial:.5}, neutral fallback {fallback:.5}"); + assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8)); +} + +fn near_black_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 }; + let rgb = [1.15, 1.0, 0.75]; + *input = rgb.map(|v| v * y / luminance_2020(rgb)); + *output = input.map(|v| v * 0.08); + } + (sensor, reference) +} + +#[test] +fn a_near_black_scene_gets_tone_without_learning_colour() { + let (sensor, reference) = near_black_scene(); + assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look"); + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default()); + let (_, _, look) = starting_colour_with( + &mut raw, + |_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false), + |_, _| None, + |_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]), + ); + let look = look.expect("tone from the dark pixels"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = |tone: &ToneMap| { + sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)).sum::() + }; + assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001); + assert_eq!(look.tone.apply(0.0), 0.0); + let mut previous = 0.0; + for i in 0..10_000 { + let y = look.tone.apply(i as f32 / 10_000.0); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + // Models with spectral matrices keep them before the dark tone is fitted. + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, false), + |_, _| None, + |raw, t| { + assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color)); + fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]) + }, + ); + assert!(!t.matrix_is_fallback); + assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY); +} + +#[test] +fn dark_tone_holdout_never_enters_training() { + let (sensor, mut reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone"); + // Every pixel clears the wider signal floors. Changing only its held-out third must + // change the measured error, but cannot change the fitted curve. + for p in reference.data.iter_mut().step_by(3) { + *p = p.map(|v| v * 1.03); + } + let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback"); + assert_eq!(changed.tone, original.tone); +} + +#[test] +fn dark_tone_requires_signal_correlation_and_unclipped_highlights() { + let (sensor, reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let mut unrelated = reference.clone(); + for (i, p) in unrelated.data.iter_mut().enumerate() { + *p = reference.data[(i * 3571) % reference.data.len()]; + } + assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG"); + let mut flat = reference.clone(); + flat.data.fill([0.002; 3]); + assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone"); + let mut sparse = reference.clone(); + sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]); + assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs"); + let mut bright = reference.clone(); + bright.data[0] = [0.95; 3]; + assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight"); + let mut clipped = none; + clipped[0] = true; + let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel"); + let mut changed = sensor.clone(); + changed.data[0] = [0.18; 3]; + let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG"); + assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve"); + let mut highlight = reference.clone(); + highlight.data[0] = [0.8; 3]; + assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG"); + assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions"); + assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask"); +} + +/// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is +/// learnt and the full look is never tried) whose camera renders it two stops brighter than the +/// neutral fallback: the partial look is the tone curve (and chroma curve) alone. +#[test] +fn partial_look_brightens_a_dull_scene_without_learning_colour() { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.01 + (i % 37) as f32 * 0.004; + let tint = if i % 50 == 0 { 1.3 } else { 1.0 + (i % 5) as f32 * 0.004 }; + *p = [ev * tint, ev, ev * (2.0 - tint)]; + } + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-12.0 * y).exp()) / y) + }; + let mut reference = sensor.clone(); + reference.map_in_place(camera); + assert!(fit_pairs(&sensor, &reference).is_none(), "too little colour for the full look"); + let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look"); + assert_eq!(look.matrix, Mat3::IDENTITY, "no colour matrix from a colourless scene"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let lightness = |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::() / sensor.data.len() as f64; + let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / reference.data.len() as f64; + assert!((lightness(&tone) / target - 1.0).abs() < 0.05, "{} vs {target}", lightness(&tone)); + assert!(lightness(&neutral) < target * 0.6, "the neutral fallback is far darker"); +} + +/// When the neutral fallback already matches the camera JPEG, or the JPEG is unrelated or +/// monochrome, the partial look isn't clearly better and the photo keeps the neutral fallback. +#[test] +fn partial_look_only_when_clearly_better_than_the_neutral_fallback() { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.02 + (i % 41) as f32 * 0.012; + *p = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.045)]; + } + let none = vec![false; w * h]; + // the camera renders exactly as the neutral fallback: nothing to gain + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let mut reference = sensor.clone(); + reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32)); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback"); + // an unrelated picture + for (i, p) in reference.data.iter_mut().enumerate() { + *p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; + } + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "unrelated JPEG"); + // a black-and-white JPEG of the scene + reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]); + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG"); +} + +/// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's +/// luminance, grey in every pixel. +fn black_and_white_scene() -> (Rgb32f, Rgb32f) { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.01 + (i % 53) as f32 * 0.009; + *p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)]; + } + let mut reference = sensor.clone(); + reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]); + (sensor, reference) +} + +#[test] +fn black_and_white_jpegs_are_recognised() { + let (sensor, mut reference) = black_and_white_scene(); + assert!(monochrome(&reference)); + assert!(!monochrome(&sensor), "a colour JPEG"); + // a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour + let n = reference.data.len(); + reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05); + assert!(monochrome(&reference)); + reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05); + assert!(!monochrome(&reference)); + // a sepia-toned monochrome + let (_, mut toned) = black_and_white_scene(); + toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]); + assert!(!monochrome(&toned)); + assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell"); + // the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts + let (_, mut faint) = black_and_white_scene(); + faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]); + assert!(monochrome(&faint)); + faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]); + assert!(!monochrome(&faint)); + // pixels that aren't numbers can't vouch for grey + let (_, mut broken) = black_and_white_scene(); + broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]); + assert!(!monochrome(&broken)); +} + +/// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's +/// brightness like the camera, with the colour left as it was and the chroma curve neutral. +#[test] +fn a_black_and_white_jpeg_gives_its_tone_curve() { + let (sensor, reference) = black_and_white_scene(); + let none = vec![false; sensor.data.len()]; + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit"); + let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve"); + assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was"); + assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma()); + let n = sensor.data.len() as f64; + let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::() / n; + let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / n; + let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)); + assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}"); + assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker"); + // with a camera profile's colour, the look carries it + let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]); + let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour"); + assert_eq!(look.matrix, matrix); +} + +/// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries +/// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's. +#[test] +fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() { + // saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped) + let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]]; + let mut sensor = Rgb32f::new(96, 64); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.02 + (i % 47) as f32 * 0.008; + *p = hues[i % 6].map(|c| c * ev); + } + // a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let mut reference = sensor.clone(); + reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]); + let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); + assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks"); + let own = fit_tone_only(&sensor, &reference, None); + assert!(own.is_some(), "the photo's own colour passes"); + // and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices) + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |_, t| { + assert!(!t.matrix_is_fallback, "on the spectral matrices"); + own.clone() + }, + |_, _| None, + ); + assert!(look.is_some() && !t.matrix_is_fallback); +} + +/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG, +/// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the +/// same file with a tinted JPEG gets nothing from this step. +#[test] +fn monochrome_look_reads_the_files_black_and_white_jpeg() { + use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect}; + let (w, h) = (192usize, 128usize); + let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); + let raw = RawImage { + format: RawFormat::Arw, + width: w, + height: h, + cpp: 3, + data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()), + cfa: None, + bits: 16, + black: BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::from_exif(1), + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: true, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, + lens_metadata: Default::default(), + }; + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG + let file = |tint: [f32; 3]| { + let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + let pixels: Vec = (0..w * h) + .flat_map(|i| { + let v = 1.0 - (-3.0 * level(i % w, i / w)).exp(); + tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + // II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it + let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] { + tiff.extend(tag.to_le_bytes()); + tiff.extend(4u16.to_le_bytes()); + tiff.extend(1u32.to_le_bytes()); + tiff.extend(value.to_le_bytes()); + } + tiff.extend(0u32.to_le_bytes()); + assert_eq!(tiff.len(), 38); + tiff.extend(jpeg); + tiff + }; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); + assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); + let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let mid = curve.apply(0.25); + assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); + assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); +} + +/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve +/// already renders, gives no look. +#[test] +fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() { + let (sensor, mut reference) = black_and_white_scene(); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]); + assert!(monochrome(&reference)); + assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve"); + for (i, p) in reference.data.iter_mut().enumerate() { + *p = [0.05 + (i % 29) as f32 * 0.02; 3]; + } + assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture"); + // a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties + // ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey) + let mut sorted = sensor.clone(); + sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b))); + reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); + assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); +} + +/// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted. +#[test] +fn corpus_arw6_full_frame_gets_a_camera_look() { + const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) }; + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/arw6-sony-ilce7rm6-ff-land-c.arw"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); +} + +/// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. +fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { + let scene = |x: f32, y: f32| -> [f32; 3] { + let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; + [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); + } + } + (sensor, reference) +} + +/// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a +/// flat and a differently sized preview. +#[test] +fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { + let (sensor, reference) = scene_pair(96, 64, 0); + assert!(structure_agrees(&sensor, &reference)); + assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); + assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); + let mut stripes = reference.clone(); + for (i, p) in stripes.data.iter_mut().enumerate() { + *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; + } + assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); + let mut flat = reference.clone(); + flat.data.fill([0.2; 3]); + assert!(!structure_agrees(&sensor, &flat)); + assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); +} + +/// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that +/// renders mid-grey brighter than the default tone map is followed. +#[test] +fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { + let (mut sensor, mut reference) = scene_pair(96, 64, 0); + for image in [&mut sensor, &mut reference] { + for p in image.data.iter_mut().step_by(5) { + *p = [p[0] * 1.6, p[1], p[2] * 0.5]; + } + } + let colour = Some((Mat3::IDENTITY, None)); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let look = + fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check) + .expect("tone fitted"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let target = 1.0 - (-2.5f32 * 0.18).exp(); + assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); + assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); + assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); +} + +/// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the +/// camera preview's, in one direction only: clipping what the camera clips is not refused. +#[test] +fn dng_highlight_gate_compares_near_white_shares() { + let mut sensor = Rgb32f::new(32, 32); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; + } + let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); + let mut white = Rgb32f::new(32, 32); + white.data.iter_mut().for_each(|p| *p = [1.0; 3]); + let mut black = Rgb32f::new(32, 32); + black.data.iter_mut().for_each(|p| *p = [0.1; 3]); + assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); + assert!(!clips_more(&sensor, &black, &dark, &bright)); + assert!(!clips_more(&sensor, &black, &bright, &bright)); + assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); +} + +/// A dark scene's shadows below the usual training floor still reach the tone-only fit. +#[test] +fn dng_tone_fit_trains_on_dark_pixels() { + let mut sensor = Rgb32f::new(64, 64); + let mut reference = Rgb32f::new(64, 64); + for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { + let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; + *s = [v, v * 1.1, v * 0.9]; + *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; + } + let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); + let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); + assert!(dark > usual, "{dark} vs {usual}"); +} + +/// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. +#[test] +fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let lens = EmbeddedLens { + vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() + }; + let mut proxy = Rgb32f::new(96, 64); + proxy.data.fill([0.2; 3]); + correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); + let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); + assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); + let mut plain = Rgb32f::new(96, 64); + plain.data.fill([0.2; 3]); + correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); + assert!(plain.data.iter().all(|p| *p == [0.2; 3])); +} + +/// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's +/// full-resolution corrections applied once rather than silently losing the tone fit. +#[test] +fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() { + use lightcraft_raw::Opcode; + let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]); + raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none()); + let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable"); + assert_eq!((proxy.width, proxy.height), (32, 24)); + let center = proxy.data[12 * 32 + 16][1]; + let corner = proxy.data[0][1]; + assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}"); + let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development"); + let small = sensor_proxy(&raw, 2, 16).unwrap(); + assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce"); +} + +/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the +/// identity, with or without a look table; and no other raw format. +#[test] +fn dng_tone_gates_follow_the_files_own_tags() { + use lightcraft_raw::profile::{HsvTable, ToneCurve}; + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { + let raw = raw_of(format, "Make", "Model", color); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + dng_tone_eligible(&raw, &t) + }; + assert!(eligible(RawFormat::Dng, color.clone())); + assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); + assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); + let mut identity = color.clone(); + identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); + assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); + let mut curved = color.clone(); + curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); + let mut looked = color; + looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); + assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone"); +} + +/// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not. +#[test] +fn dng_tone_fit_takes_lightroom_sized_previews() { + let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0); + assert!(1024.0 >= floor(7008.0)); + assert!(256.0 < floor(7008.0)); + assert!(1024.0 >= floor(9504.0), "a 61 MP raw too"); +} + +/// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped +/// to the JPEG through a steep camera curve (a hazy scene the camera stretches). +fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> { + (0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect() +} + +/// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that +/// of the darkest 1/32 (their last and first knots). +fn knot_range(mut scene: Vec) -> f64 { + scene.sort_by(f64::total_cmp); + let n = scene.len(); + scene[31 * n / 32 + n / 64] / scene[n / 64] +} + +/// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets +/// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit. +#[test] +fn tone_fits_accept_low_contrast_scenes() { + for fit in [fit_tone, fit_tone_conditional] { + let pairs = low_contrast_pairs(1.47); + let range = knot_range(pairs.iter().map(|p| p.0).collect()); + assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}"); + let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631"); + assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1)); + assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame"); + } +} + +/// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera +/// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback. +#[test] +fn a_low_contrast_scene_gets_a_camera_look() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y); + } + let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback"); + let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error: f64 = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.02, "{}", error.sqrt()); +} + +/// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the +/// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out +/// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky +/// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries' +/// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright +/// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range). +#[test] +fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + for (case, sky_value, is_sky) in [ + ("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool), + ("clipped sky", 1.0, &|i: usize| i < 5600), + ("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)), + ("sky over the pairs' range", 1.6, &|i: usize| i < 5600), + ] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + let mut clipped = vec![false; sensor.data.len()]; + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if is_sky(i) { + // bright, nearly white in the camera JPEG + *src = [sky_value; 3]; + *dst = [0.93; 3]; + clipped[i] = case == "clipped sky"; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + // nearly flat midtones: the fitted curve's extrapolation can't reach the sky + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y); + } + let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([f64::from(sky_value); 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}"); + } +} + +/// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to +/// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the +/// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the +/// sky at 0.61 where the camera JPEG has 0.93. +#[test] +fn a_narrow_curve_is_checked_as_lowered_for_dro() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y); + } + let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0))); + if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) { + let look = with_dro_off_tone(look); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([0.9; 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}"); + } +} + +/// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning +/// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted. +#[test] +fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3]; + } + let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2)); + let rms = (error.sum::() / sensor.data.len() as f64).sqrt(); + assert!(rms < 0.02, "{rms}"); +} + +/// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is +/// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit. +#[test] +fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3]; + } + if let Some(look) = fit_tone_only(&sensor, &reference, None) { + let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0))); + assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}"); + } +} diff --git a/crates/engine/src/camera_preview/tone.rs b/crates/engine/src/camera_preview/tone.rs new file mode 100644 index 000000000..19b66948c --- /dev/null +++ b/crates/engine/src/camera_preview/tone.rs @@ -0,0 +1,186 @@ +use super::look_fit::dro_off; +use super::pairs::{Pairs, displayed, luma}; +use super::*; + +/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum ToneFit { + /// [`fit_tone`]: no flat steps, no pixel-exact registration needed. + Quantile, + /// [`fit_tone_conditional`]: the earlier fit, kept as the fallback. + ConditionalMedian, +} + +impl ToneFit { + pub(super) fn fit(self, pairs: Vec<(f64, f64)>) -> Option { + match self { + ToneFit::Quantile => fit_tone(pairs), + ToneFit::ConditionalMedian => fit_tone_conditional(pairs), + } + } +} + +/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel +/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped +/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped +/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can +/// leave them out. +pub(super) fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs { + if sensor.data.len() != reference.data.len() { + return Vec::new(); + } + sensor + .data + .iter() + .zip(&reference.data) + .filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85) + .map(|(x, y)| (x.map(f64::from), y.map(f64::from))) + .collect() +} + +/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the +/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are +/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be +/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance +/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very +/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial +/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of +/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0. +/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`]. +const MIN_TONE_RANGE: f32 = 1.2; + +/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also +/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does +/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG +/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted +/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed +/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has +/// 0.93. +const NARROW_TONE_RANGE: f32 = 1.5; + +/// What a narrow tone curve is checked against ([`highlights_hold`]). +pub(super) struct HighlightCheck { + /// The frame's [`highlights`]. + pub(super) pairs: Pairs, + /// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the + /// fit, so the check looks at it lowered. + pub(super) dro: bool, + /// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that + /// fit's gates do, since the JPEG has no colour to match. + pub(super) grey: bool, +} + +/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps +/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further +/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get +/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than +/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels. +pub(super) fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool { + let knots = tone.knots(); + if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE { + return true; + } + // the curve as the photo will get it + let used = if check.dro { dro_off(tone) } else { *tone }; + let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let (mut look, mut fallback) = (0.0, 0.0); + for (x, target) in &check.pairs { + let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral)); + if check.grey { + look += (luma(rendered) - luma(*target)).powi(2); + fallback += (luma(base) - luma(*target)).powi(2); + continue; + } + for c in 0..3 { + look += (rendered[c] - target[c]).powi(2); + fallback += (base[c] - target[c]).powi(2); + } + } + let hold = look.is_finite() && look <= fallback; + if !hold && lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})"); + } + hold +} + +/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs +/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th +/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the +/// scene to the same quantile of the JPEG; matching the two distributions needs no pixel-exact +/// registration and is increasing by construction. +/// +/// Binning the pairs by scene luminance and taking the JPEG's median per bin instead (followed by +/// isotonic pooling) fails on real files: camera JPEGs are lens-corrected and sharpened, so on a +/// 96 px proxy of a busy scene neighbouring pairs disagree by more than the gap between bins. +/// The conditional medians then zig-zag, pooling turns them into flat steps (13 of 31 segments +/// on a Z 6II forest, issue #475) that render as posterised patches, and regression toward the +/// mean flattens both ends of the curve (lifted blacks, dull highlights). +pub(super) fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { + if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { + return None; + } + let mut xs: Vec = pairs.iter().map(|p| p.0).collect(); + let mut ys: Vec = pairs.iter().map(|p| p.1).collect(); + xs.sort_by(f64::total_cmp); + ys.sort_by(f64::total_cmp); + let n = xs.len(); + let mut knots = [[0.0; 2]; 32]; + for (i, knot) in knots.iter_mut().enumerate() { + let (a, b) = (i * n / 32, (i + 1) * n / 32); + let mid = a + (b - a) / 2; + *knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32]; + } + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { + return None; + } + CameraTone::new(knots) +} + +fn median(values: &mut [f64]) -> Option { + values.sort_by(f64::total_cmp); + values.get(values.len() / 2).copied() +} + +/// The earlier tone fit, kept as the fallback of [`ToneFit`]: the JPEG's median per equal-population +/// bin of scene luminance, then pooled-adjacent-violators isotonic regression. It follows the +/// per-pixel pairs closely when the two images are registered, which is why it can clear the +/// acceptance gates where [`fit_tone`] just misses them, but on lens-corrected JPEGs of busy scenes +/// the pooling leaves flat steps (issue #475). +pub(super) fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { + if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { + return None; + } + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut knots = [[0.0; 2]; 32]; + for (i, knot) in knots.iter_mut().enumerate() { + let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?; + let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect(); + let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect(); + *knot = [median(&mut xs)? as f32, median(&mut ys)? as f32]; + } + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { + return None; + } + // Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial. + let mut blocks: Vec<(f32, usize)> = Vec::new(); + for knot in knots { + blocks.push((knot[1], 1)); + while blocks.len() >= 2 { + let (a, an) = *blocks.get(blocks.len() - 2)?; + let (b, bn) = *blocks.last()?; + if a <= b { + break; + } + blocks.truncate(blocks.len() - 2); + blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn)); + } + } + let mut i = 0; + for (y, n) in blocks { + for knot in knots.get_mut(i..i + n)? { + knot[1] = y; + } + i += n; + } + CameraTone::new(knots) +} diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index c7b6c437b..9e6d2094e 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -28,6 +28,7 @@ pub const BUNDLED: &[(&str, &str)] = &[ ("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")), ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("ILCE-7RM5", include_str!("../../../assets/camera-profiles/ILCE-7RM5.json")), ("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")), ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), ("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/activity.rs b/crates/engine/src/cmd/activity.rs index af8667728..ed4e8d35c 100644 --- a/crates/engine/src/cmd/activity.rs +++ b/crates/engine/src/cmd/activity.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Cancel Background Task", [], None, - "{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error", + "{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo (a preview build also stops between a photo's own previews). An unknown id (it may have just finished) or a task that can't be cancelled is an error", always, |s, p| { const C: &str = "activity.cancel"; diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 07dcbe0ec..ebed426fa 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -154,6 +154,16 @@ pub fn keyword_sets_json(s: &Session) -> Value { json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)}) } +/// Make the set `step` places after the current one current, in the Keyword Set menu's order +/// (Recent Keywords, then the saved sets), wrapping around from the last to the first and back. +fn step_set(s: &mut Session, step: isize) -> Result { + let at = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().position(|x| same_set(&x.name, n))).map_or(0, |i| i + 1); + let next = (at as isize + step).rem_euclid(s.keyword_sets.len() as isize + 1) as usize; + s.keyword_set = next.checked_sub(1).and_then(|i| s.keyword_sets.get(i)).map(|x| x.name.clone()); + s.save_prefs()?; + Ok(keyword_sets_json(s)) +} + pub fn specs() -> Vec { vec![ cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))), @@ -174,6 +184,24 @@ pub fn specs() -> Vec { s.save_prefs()?; Ok(keyword_sets_json(s)) }), + cmd!( + "keyword.nextSet", + "Next Keyword Set", + [], + Some("Alt+0"), + "{}: the set after the current one (after the last, Recent Keywords)", + always, + |s, _| step_set(s, 1) + ), + cmd!( + "keyword.previousSet", + "Previous Keyword Set", + [], + Some("Alt+Shift+0"), + "{}: the set before the current one (before Recent Keywords, the last)", + always, + |s, _| step_set(s, -1) + ), cmd!( "keyword.saveSet", "Save Keyword Set", diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 9d11cc5b3..5d1b0fef1 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -7,1873 +7,15 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param}; use crate::{LibrarySource, Result, Selection, Session}; -/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). -/// Its callers end an open edit (a slider drag) before their own change, as moving to the next -/// photo does, so it is its own undo step and comes first. -fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { - let at = before.iter().position(|id| Some(*id) == s.selection.active); - let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); - s.selection.ids.retain(|id| remaining.contains(id)); - s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); - if s.selection.active.is_none() - && let Some(at) = at - { - // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. - let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); - s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); - } -} +mod filters; +mod gps; +mod import; +mod selection; +mod specs; -/// The auto-import folder's visible files with their sizes (the file-system half of -/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). -pub fn list_auto_import_folder(folder: &str) -> std::result::Result, String> { - let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?; - Ok(rd - .flatten() - .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) - .filter_map(|e| { - let m = std::fs::metadata(e.path()).ok()?; - m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len())) - }) - .collect()) -} - -/// `library.import`'s params, checked (no file-system calls). -pub struct ImportRequest { - pub paths: Vec, - pub opts: crate::import::ImportOptions, - pub album: Option, - pub album_name: Option, -} - -/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a -/// worker thread, with the same options). -pub fn import_params(s: &Session, p: &Value) -> Result { - let paths = strs(p, "paths"); - let mode = match str_param(p, "mode").unwrap_or("add") { - "add" => crate::import::ImportMode::Add, - "copy" => crate::import::ImportMode::Copy, - "move" => crate::import::ImportMode::Move, - other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))), - }; - let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) { - Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?), - None => None, - }; - let album = p.get("album").and_then(Value::as_u64); - if let Some(a) = album - && s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart()) - { - return Err(bad("library.import", "album must be a regular album")); - } - let organize = match str_param(p, "organize") { - Some(o) => crate::import::Organize::parse(o).ok_or_else(|| { - bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})")) - })?, - None => Default::default(), - }; - if let crate::import::Organize::Template(t) = &organize - && let Some(e) = crate::rename::folder_template_error(t) - { - return Err(bad("library.import", e)); - } - let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if let Some(n) = &metadata_preset - && !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) - { - return Err(bad("library.import", format!("unknown metadata preset `{n}`"))); - } - let on_deleted = match str_param(p, "onDeleted") { - Some(v) => { - crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))? - } - None => Default::default(), - }; - let opts = crate::import::ImportOptions { - on_deleted, - mode, - preset, - keywords: strs(p, "keywords"), - destination: str_param(p, "destination").map(str::to_string), - organize, - rename: str_param(p, "rename").map(str::to_string), - rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize, - metadata_preset, - convert_dng: bool_or(p, "dng", false), - local: bool_or(p, "local", false), - }; - let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - Ok(ImportRequest { paths, opts, album, album_name }) -} - -/// After a batch of an import was committed: the album (a new one named `album_name` when there -/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with -/// `album`). -pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option, album_name: Option<&str>) -> Result { - let mut report = serde_json::to_value(report).unwrap_or_default(); - // photos an import restored from Recently Deleted take part like new ones (album, selection) - let imported: Vec = - ["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect(); - if album.is_none() - && let Some(name) = album_name - && !imported.is_empty() - { - let r = s.execute("album.create", &json!({"name": name}))?; - album = r["id"].as_u64(); - } - if let Some(a) = album - && !imported.is_empty() - { - // (album.addPhotos wants a selection: the new photos are about to be it) - s.selection = Selection::single(PhotoId(imported[0])); - s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?; - report["album"] = json!(a); - } - Ok(report) -} - -fn album_param(p: &Value, key: &str, c: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`"))) -} - -/// An optional album id: absent or `null` is `None`, anything but a number is an error. -fn opt_album_param(p: &Value, key: &str, c: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))), - } -} - -fn strs(p: &Value, key: &str) -> Vec { - p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() -} - -/// The targets an album can hold and how many were browsed (Local) photos, which album views -/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members. -fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) { - let before = targets.len(); - let kept: Vec = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect(); - let skipped = before - kept.len(); - (kept, skipped) -} - -/// Album targets, or the error for a selection that was nothing but Local photos. -fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Result<(Vec, usize)> { - let (kept, skipped) = album_targets(s, targets); - if kept.is_empty() && skipped > 0 { - return Err(bad(command, super::LOCAL_ONLY)); - } - Ok((kept, skipped)) -} - -/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep -/// showing `i/n` (issue #1493). -fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result { - let mut ops: Vec = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); - let n = ops.len(); - if n == 0 { - return Ok(0); - } - ops.extend(s.catalog.remove_from_stacks_ops(ids)); - s.commit(label, Op::Batch { ops })?; - Ok(n) -} - -/// Apply one op per target as a single undo step. -fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { - let targets = s.targets(p)?; - let ops: Vec = targets.iter().filter_map(|id| f(*id)).collect(); - let n = ops.len(); - if n > 0 { - s.commit(label, Op::Batch { ops })?; - } - Ok(json!({"changed": n})) -} - -/// Adjust each target's own rating; unchanged bounds do not create an undo step. -fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result { - let mut ops = Vec::new(); - for id in s.targets(p)? { - let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; - let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; - if rating != old { - ops.push(Op::SetRating { id, rating }); - } - } - let n = ops.len(); - if n > 0 { - s.commit(label, Op::Batch { ops })?; - } - Ok(json!({"changed": n})) -} - -/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged, -/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step. -fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result { - let mut ops = Vec::new(); - for id in s.targets(p)? { - let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag; - let flag = step(old); - if flag != old { - ops.push(Op::SetFlag { id, flag }); - } - } - let n = ops.len(); - if n > 0 { - s.commit(label, Op::Batch { ops })?; - } - Ok(json!({"changed": n})) -} - -/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar, -/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is -/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so -/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey -/// keep showing the photo they rate. -fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result) -> Result { - let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null())); - // the view as it was, then an open drag ends first, as its own undo step (the selection may - // move on; the drag itself can take the photo out of the view) - let before = (!named).then(|| s.visible_shared().1); - if !named { - s.end_interaction()?; - } - let active = s.selection.active; - let v = edit(s)?; - if let Some(before) = before - && active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a)) - { - select_after_deletion(s, &before); - s.active_mask = None; - s.active_spot = None; - } else { - advance_if(s, p); - } - Ok(v) -} - -fn advance_if(s: &mut Session, p: &Value) { - if bool_or(p, "advance", false) { - let _ = step(s, 1); - } -} - -fn step(s: &mut Session, d: isize) -> Result { - let vis = s.visible_cloned(); - if vis.is_empty() { - return ok(); - } - let cur = s.selection.active.and_then(|a| vis.iter().position(|x| *x == a)); - let next = match cur { - Some(i) => (i as isize + d).clamp(0, vis.len() as isize - 1) as usize, - None => 0, - }; - s.end_interaction()?; - s.selection = Selection::single(vis[next]); - s.active_mask = None; - s.active_spot = None; - Ok(json!({"active": vis[next].0})) -} - -/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input -/// can't overflow). -fn row_step(p: &Value) -> isize { - p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize -} - -fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { - let targets = s.targets(p)?; - let mut ops = Vec::new(); - for id in targets { - if let Some(d) = s.develop_of(id) { - let mut d = (*d).clone(); - d.orientation = d.orientation.rotated(cw); - // the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo) - let turn = lightcraft_geom::Orientation::Normal.rotated(cw); - d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect); - d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w)); - ops.extend(s.develop_op(id, d, "Rotate")); - } - } - let n = ops.len(); - s.commit(if cw { "Rotate Right" } else { "Rotate Left" }, Op::Batch { ops })?; - Ok(json!({"changed": n})) -} - -fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { - let targets = s.targets(p)?; - let mut ops = Vec::new(); - for id in targets { - if let Some(d) = s.develop_of(id) { - let mut d = (*d).clone(); - if horizontal { - d.crop.flip_h = !d.crop.flip_h; - } else { - d.crop.flip_v = !d.crop.flip_v; - } - ops.extend(s.develop_op(id, d, "Flip")); - } - } - let n = ops.len(); - s.commit("Flip", Op::Batch { ops })?; - Ok(json!({"changed": n})) -} - -/// `library.selectBy`: select the photos in view that match every given criterion. -fn select_by(s: &mut Session, p: &Value) -> Result { - const C: &str = "library.selectBy"; - let flag: Option = match p.get("flag") { - Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(C, "flag is pick, reject or none"))?), - None => None, - }; - let rating = p.get("rating").and_then(Value::as_u64).map(|r| r.min(5) as u8); - let op = p.get("ratingOp").and_then(Value::as_str).unwrap_or("gte"); - let label: Option> = match p.get("label").and_then(Value::as_str) { - Some("none") => Some(None), - Some(l) => Some(Some(serde_json::from_value(json!(l)).map_err(|_| bad(C, format!("unknown label `{l}`")))?)), - None => None, - }; - if flag.is_none() && rating.is_none() && label.is_none() { - return Err(bad(C, "give flag, rating and/or label")); - } - let matches = |ph: &lightcraft_catalog::Photo| { - flag.is_none_or(|f| ph.flag == f) - && rating.is_none_or(|r| match op { - "eq" => ph.rating == r, - "lte" => ph.rating <= r, - _ => ph.rating >= r, - }) - && label.is_none_or(|l| ph.label == l) - }; - let mut ids: Vec = if bool_or(p, "add", false) { s.selection.ids.clone() } else { Vec::new() }; - for id in s.visible_cloned() { - if !ids.contains(&id) && s.catalog.photo(id).is_some_and(|ph| matches(ph)) { - ids.push(id); - } - } - let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied()); - let n = ids.len(); - s.selection = Selection { ids, active }; - Ok(json!({"selected": n})) -} - -/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. -const MAX_SEED: u64 = 1 << 53; - -/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error. -fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { - match p.get("seed") { - None | Some(Value::Null) => Ok(default), - Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")), - } -} - -/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles -/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON -/// clients that read numbers as doubles (web, MCP agents) get the same seed back. -fn next_seed(prev: u64, now: &str) -> u64 { - let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b))); - (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1) -} - -pub fn specs() -> Vec { - vec![ - // ---- view source / filter / sort - cmd!( - "library.source", - "Show Source", - [], - None, - "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}", - always, - |s, p| { - let kind = str_param(p, "kind").unwrap_or("all"); - s.source = match kind { - "all" => LibrarySource::All, - "recentlyAdded" => LibrarySource::RecentlyAdded, - "recentlyDeleted" => LibrarySource::RecentlyDeleted, - "picks" => LibrarySource::Picks, - "missing" => LibrarySource::Missing, - "album" => { - let a = album_param(p, "id", "library.source")?; - if s.catalog.album(a).is_none_or(|a| a.folder) { - return Err(bad("library.source", "no such album")); - } - // one album at a time: `to_filter` makes the album shown replace the filter's, and the - // filter bar has no album chip, so a left-over one would narrow the next source unseen - s.filter.album = None; - LibrarySource::Album(a) - } - "libraryFolder" => { - let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty()); - let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?; - if lightcraft_catalog::folders::is_startup_disk(path) { - return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it")); - } - let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; - let ids = s.catalog.query(&f, &Sort::default()); - if ids.is_empty() { - return Err(bad("library.source", format!("{path}: no photo in the library was imported from it"))); - } - if covers_other_disks(s, path, &ids) { - return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one"))); - } - s.library_folder = Some(path.to_string()); - // one folder at a time: a folder filter left over would be ignored - s.filter.library_folder = None; - LibrarySource::LibraryFolder - } - other => return Err(bad("library.source", format!("unknown source `{other}`"))), - }; - s.keep_active_in_view(); - Ok(json!({"count": s.visible().len()})) - } - ), - cmd!( - "library.filter", - "Filter", - [], - None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}", - always, - |s, p| { - let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); - lightcraft_develop::presets::deep_merge(&mut v, p); - let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; - let mut f = f; - // a blank folder is no folder: no hidden "filters active" state - f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty()); - if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { - return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); - } - // checked like a smart album's rules when this call sets them: an unknown field or a value - // that can't match is an error, not an empty grid. Rules that stopped checking since - // (their album was deleted) don't block other filter changes. - if let Some(rules) = f.rule_set.as_mut() { - rules.upgrade(); - } - let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null()); - if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { - return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); - } - s.filter = f; - s.shelved_filter = None; - Ok(json!({"count": s.visible().len()})) - } - ), - cmd!( - query "library.folders", - "Library Folders", - [], - None, - "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", - always, - |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) - ), - cmd!( - "library.removeFolder", - "Remove Folder from Library", - [], - None, - "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}", - always, - |s, p| { - const C: &str = "library.removeFolder"; - let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; - if lightcraft_catalog::query::folder_key(path).is_empty() { - return Err(bad(C, format!("{path}: not a folder"))); - } - if lightcraft_catalog::folders::is_startup_disk(path) { - return Err(bad(C, "choose a folder, not the whole startup disk")); - } - if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) { - return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it"))); - } - let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; - let ids = s.catalog.query(&f, &Sort::default()); - if ids.is_empty() { - return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); - } - if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { - return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); - } - mark_deleted(s, &ids, "Remove Folder from Library")?; - if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { - s.filter.library_folder = None; - } - // a filter put aside by Enable Filters must not bring the removed folder back - if let Some(f) = s.shelved_filter.as_mut() - && f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) - { - f.library_folder = None; - } - if s.shelved_filter.as_ref() == Some(&Filter::default()) { - s.shelved_filter = None; - } - // the folder being shown is gone: back to everything, as for a deleted album - if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { - s.library_folder = None; - if s.source == LibrarySource::LibraryFolder { - s.source = LibrarySource::All; - } - } - let vis = s.visible_cloned(); - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); - Ok(json!({"removed": ids.len()})) - } - ), - cmd!( - "folder.label", - "Set Folder Color Label", - [], - None, - "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}", - always, - |s, p| { - const C: &str = "folder.label"; - let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; - let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? { - "none" => None, - x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?), - }; - // taking a label off always works, also once the folder's photos are gone - let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; - if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() { - return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); - } - if label.is_none() && s.catalog.folder_record(path).is_none() { - return Ok(json!({"path": path, "label": label})); - } - let op = s.catalog.folder_label_op(path, label); - s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?; - Ok(json!({"path": path, "label": label})) - } - ), - cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { - s.filter = Default::default(); - s.shelved_filter = None; - Ok(json!({"count": s.visible().len()})) - }), - // Classic: Library > Enable Filters (Cmd+L) switches the filter off and on again as it was - cmd!( - "library.toggleFilters", - "Enable Filters", - ["View"], - Some("Cmd+L"), - "{} — with a filter set, puts it aside and shows everything; with one put aside, brings it back as it was (stars, flag, text… together). Setting or clearing a filter while it is off forgets the one put aside → {enabled, count}", - always, - |s, _| { - if s.filter != Filter::default() { - s.shelved_filter = Some(std::mem::take(&mut s.filter)); - } else if let Some(mut f) = s.shelved_filter.take() { - // one folder at a time: the folder shown now wins over the one the filter had - if s.source == LibrarySource::LibraryFolder { - f.library_folder = None; - } - s.filter = f; - } - Ok(json!({"enabled": s.shelved_filter.is_none(), "count": s.visible().len()})) - } - ), - cmd!( - "library.sort", - "Sort", - ["View", "Sort"], - None, - "{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}", - always, - |s, p| { - let key: SortKey = match p.get("key") { - Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?, - None => s.sort.key, - }; - let group = match str_param(p, "group") { - Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?, - None => s.sort.group, - }; - let mut seed = seed_param(p, "library.sort", s.sort.seed)?; - let explicit = p.get("seed").is_some_and(|v| !v.is_null()); - if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit { - // switching to Random is a fresh shuffle, not whatever seed was left behind - seed = next_seed(seed, &(s.clock)()); - } - s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed }; - ok() - } - ), - cmd!( - "library.refresh", - "Refresh View", - ["View"], - None, - "{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed", - always, - |s, _| { - s.refresh_view(); - ok() - } - ), - cmd!( - "library.holdView", - "Hold View Until Refreshed", - ["View"], - None, - "{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing", - always, - |s, p| { - let on = match p.get("on") { - None | Some(Value::Null) => !s.hold_view, - Some(Value::Bool(on)) => *on, - Some(_) => return Err(bad("library.holdView", "on must be true or false")), - }; - if on != s.hold_view { - let was = s.hold_view; - s.hold_view = on; - if let Err(e) = s.save_prefs() { - // not kept: not changed either, so asking again does what was asked - s.hold_view = was; - return Err(e); - } - s.refresh_view(); - } - Ok(json!({"hold": s.hold_view})) - } - ), - cmd!( - "library.shuffle", - "Reshuffle", - ["View", "Sort"], - None, - "{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time", - always, - |s, p| { - let seed = match seed_param(p, "library.shuffle", s.sort.seed)? { - given if p.get("seed").is_some_and(|v| !v.is_null()) => given, - // derived from the previous seed and the session clock: reproducible under a test - // clock, and no RNG needed - prev => next_seed(prev, &(s.clock)()), - }; - s.sort = Sort { key: SortKey::Random, seed, ..s.sort }; - ok() - } - ), - cmd!( - query "library.groups", - "Date Groups", - [], - None, - "{by?: day|month|year (default: the sort's grouping; auto = day)} → [{key, label, start, count}] date headers of the grid", - always, - |s, p| { - let by = match str_param(p, "by") { - Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.groups", "by must be auto|none|day|month|year"))?, - None => s.sort.group, - }; - let vis = s.visible_cloned(); - Ok(serde_json::to_value(s.catalog.date_runs(&vis, s.sort.key, by)).unwrap_or_default()) - } - ), - // ---- selection - cmd!( - "library.select", - "Select Photos", - [], - None, - "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library", - always, - |s, p| { - let ids = super::photo_ids(p, "library.select")?.unwrap_or_default(); - let active = p.get("active").and_then(Value::as_u64).map(PhotoId); - let mode = str_param(p, "mode").unwrap_or("replace"); - // An id that is not in the library is an error, and the selection stays as it was: - // selecting it would otherwise report success and leave the session with no usable - // active photo (#182). - if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) { - return Err(bad("library.select", format!("no such photo {}", id.0))); - } - s.end_interaction()?; - match mode { - "replace" => { - s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) }; - } - "add" => { - for id in ids { - if !s.selection.contains(id) { - s.selection.ids.push(id); - } - s.selection.active = Some(id); - } - } - "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), - "range" => { - let vis = s.visible_cloned(); - if let Some(id) = ids.first() { - s.selection.extend_to(*id, &vis); - } - } - other => return Err(bad("library.select", format!("unknown mode `{other}`"))), - } - s.active_mask = None; - s.active_spot = None; - Ok(json!({"selected": s.selection.ids.len()})) - } - ), - cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { - let vis = s.visible_cloned(); - s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; - Ok(json!({"selected": s.selection.ids.len()})) - }), - cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { - Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default()) - }), - cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| { - let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; - let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta; - let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description, - "copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url, - "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country, - "keywords": m.keywords}); - s.meta_clipboard = Some(v.clone()); - Ok(v) - }), - cmd!( - "photo.pasteMetadata", - "Paste Metadata", - ["Photo"], - None, - "{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}", - has_selection, - |s, p| { - let c = "photo.pasteMetadata"; - let clip = s.meta_clipboard.clone().ok_or_else(|| bad(c, "nothing copied (photo.copyMetadata)"))?; - let fields: Option> = - p.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - let mut params = serde_json::Map::new(); - for (k, v) in clip.as_object().into_iter().flatten() { - if fields.as_ref().is_none_or(|f| f.iter().any(|x| x == k)) { - params.insert(k.clone(), v.clone()); - } - } - if let Some(ids) = p.get("ids") { - params.insert("ids".into(), ids.clone()); - } - s.execute("photo.setMeta", &Value::Object(params)) - } - ), - cmd!( - "library.selectBy", - "Select by Flag, Rating or Label", - [], - None, - "{flag?: pick|reject|none, rating?: 0..5, ratingOp?: gte|eq|lte (default gte), label?: red|…|none, add?: bool (extend the selection)} — among the photos in view", - always, - select_by - ), - cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { - let a = s.selection.active; - s.selection = Selection { ids: a.into_iter().collect(), active: a }; - ok() - }), - cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)), - cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)), - cmd!( - "library.down", - "Photo Below", - [], - Some("Down"), - "{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}", - always, - |s, p| { step(s, row_step(p)) } - ), - cmd!( - "library.up", - "Photo Above", - [], - Some("Up"), - "{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}", - always, - |s, p| { step(s, -row_step(p)) } - ), - // ---- rating / flags / labels - cmd!( - "photo.rate", - "Set Rating", - ["Photo", "Set Rating"], - None, - "{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - let r = super::index_req(p, "rating", "photo.rate") - .ok() - .filter(|r| *r <= 5) - .ok_or_else(|| bad("photo.rate", "rating must be a whole number 0..5"))?; - cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))) - } - ), - cmd!( - "photo.decreaseRating", - "Decrease Rating", - [], - None, - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) } - ), - cmd!( - "photo.increaseRating", - "Increase Rating", - [], - None, - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) } - ), - cmd!( - "photo.flag", - "Set Flag", - ["Photo", "Set Flag"], - None, - "{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; - cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))) - } - ), - // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) - cmd!( - "photo.cycleFlag", - "Cycle Flag", - ["Photo", "Set Flag"], - Some("`"), - "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - cull(s, p, |s| { - change_flag(s, p, "Cycle Flag", |f| match f { - Flag::None => Flag::Pick, - Flag::Pick => Flag::Reject, - Flag::Reject => Flag::None, - }) - }) - } - ), - cmd!( - "photo.increaseFlag", - "Increase Flag Status", - ["Photo", "Set Flag"], - Some("Cmd+Up"), - "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) } - ), - cmd!( - "photo.decreaseFlag", - "Decrease Flag Status", - ["Photo", "Set Flag"], - Some("Cmd+Down"), - "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) } - ), - cmd!( - "photo.pick", - "Flag as Pick", - ["Photo", "Set Flag"], - Some("P"), - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) } - ), - cmd!( - "photo.reject", - "Flag as Reject", - ["Photo", "Set Flag"], - Some("X"), - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) } - ), - cmd!( - "photo.unflag", - "Unflag", - ["Photo", "Set Flag"], - Some("U"), - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) } - ), - cmd!( - "photo.label", - "Set Color Label", - ["Photo", "Set Color Label"], - None, - "{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear - let l = match p.get("label") { - Some(Value::Null) => None, - Some(Value::String(x)) if x == "none" => None, - Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?), - None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")), - Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")), - }; - cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))) - } - ), - // ---- orientation - cmd!("photo.rotateLeft", "Rotate Left", ["Photo"], Some("Cmd+["), "{ids?}", has_selection, |s, p| rotate(s, p, false)), - cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)), - cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)), - cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)), - // ---- face / pet regions - cmd!( - "photo.removeRegion", - "Remove Face Box", - [], - None, - "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", - always, - |s, p| { - let id = super::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; - let index = p - .get("index") - .and_then(Value::as_u64) - .and_then(|i| usize::try_from(i).ok()) - .ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?; - let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone(); - if index >= meta.regions.len() { - return Err(bad("photo.removeRegion", "no such region")); - } - let gone = meta.regions.remove(index); - s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; - s.skip_auto_write = true; - Ok(json!({"removed": gone.name})) - } - ), - cmd!( - "photo.setRegion", - "Resize Face Box", - [], - None, - "{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar", - always, - |s, p| { - const C: &str = "photo.setRegion"; - // a drag is one undo step however many frames it took: the caller commits once, on release - let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; - let index = p - .get("index") - .and_then(Value::as_u64) - .and_then(|i| usize::try_from(i).ok()) - .ok_or_else(|| bad(C, "missing or invalid `index`"))?; - let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?; - let num = |k: &str| { - r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number"))) - }; - let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?); - let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0)); - let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0)); - if x1 - x0 < 0.005 || y1 - y0 < 0.005 { - return Err(bad(C, "the box would be too small")); - } - let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone(); - let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?; - region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 }; - s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; - s.skip_auto_write = true; - Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}})) - } - ), - // ---- delete / restore - cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { - let before = s.visible_cloned(); - let targets = s.targets(p)?; - // an open drag ends first, as its own undo step (the selection moves on) - s.end_interaction()?; - let n = mark_deleted(s, &targets, "Delete")?; - select_after_deletion(s, &before); - Ok(json!({"changed": n})) - }), - cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { - id, - deleted: false - }))), - cmd!( - "library.emptyRecentlyDeleted", - "Empty Recently Deleted", - ["Photo"], - None, - "{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)", - always, - |s, _| { - let ids: Vec = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); - if ids.is_empty() { - return Ok(json!({"deleted": 0})); - } - let op = s.catalog.delete_photos_permanently_ops(&ids); - s.commit("Empty Recently Deleted", op)?; - s.selection = Selection::default(); - Ok(json!({"deleted": ids.len()})) - } - ), - cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { - let before = s.visible_cloned(); - let t = s.targets(p)?; - // before the inverse ops are taken, so undo brings the photo back with its edit - s.end_interaction()?; - let op = s.catalog.delete_photos_permanently_ops(&t); - s.commit("Delete Permanently", op)?; - select_after_deletion(s, &before); - Ok(json!({"deleted": t.len()})) - }), - // ---- metadata - cmd!( - "photo.setMeta", - "Edit Info", - [], - None, - "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", - has_selection, - |s, p| { - let strs = - |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::>()); - let targets = s.targets(p)?; - // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear - let gps: Option> = match p.get("gps") { - None => None, - Some(Value::Null) => Some(None), - Some(Value::String(t)) if t.trim().is_empty() => Some(None), - Some(Value::String(t)) => Some(Some( - parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?, - )), - Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) { - (Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))), - _ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")), - }, - Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")), - }; - let status = match str_param(p, "copyrightStatus") { - Some(t) => Some( - CopyrightStatus::parse(t) - .ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?, - ), - None => None, - }; - let mut ops = Vec::new(); - for id in targets { - let Some(ph) = s.catalog.photo(id) else { continue }; - let mut m = ph.meta.clone(); - if let Some(g) = gps { - m.gps = g; - } - if let Some(st) = status { - m.copyright_status = st; - } - for (k, field) in [ - ("title", &mut m.title), - ("caption", &mut m.caption), - ("copyright", &mut m.copyright), - ("usageTerms", &mut m.usage_terms), - ("copyrightUrl", &mut m.copyright_url), - ("creator", &mut m.creator), - ("location", &mut m.location), - ("city", &mut m.city), - ("state", &mut m.state), - ("country", &mut m.country), - ("altText", &mut m.alt_text), - ("extendedDescription", &mut m.extended_description), - ] { - if let Some(v) = str_param(p, k) { - *field = v.to_string(); - } - } - // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case - use lightcraft_catalog::keywords::{clean, same}; - let add = |m: &mut lightcraft_catalog::Meta, k: &str| { - let k = clean(k); - if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) { - m.keywords.push(k); - } - }; - if let Some(k) = strs("keywords") { - m.keywords.clear(); - for k in k { - add(&mut m, &k); - } - } - for k in strs("addKeywords").unwrap_or_default() { - add(&mut m, &k); - } - for k in strs("removeKeywords").unwrap_or_default() { - let k = clean(&k); - m.keywords.retain(|x| !same(&clean(x), &k)); - } - ops.push(Op::SetMeta { id, meta: Box::new(m) }); - } - let n = ops.len(); - s.commit("Edit Info", Op::Batch { ops })?; - // keywords just added become the Recent Keywords set - let added: Vec = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect(); - if n > 0 && !added.is_empty() { - crate::cmd::keywords::note_recent(s, &added); - } - Ok(json!({"changed": n})) - } - ), - // ---- albums - cmd!( - "album.create", - "New Album", - ["File"], - None, - "{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)", - always, - |s, p| { - let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); - if name.is_empty() { - return Err(bad("album.create", "empty name")); - } - let folder = bool_or(p, "folder", false); - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; - let id = s.catalog.alloc_album_id(); - let cover = photos.first().copied(); - s.commit( - if folder { "New Folder" } else { "New Album" }, - Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, - )?; - Ok(json!({"id": id.0, "skipped": skipped})) - } - ), - cmd!( - "album.createSmart", - "New Smart Album…", - [], - None, - "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)", - always, - |s, p| { - let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string(); - if name.is_empty() { - return Err(bad("album.createSmart", "empty name")); - } - let rules = match p.get("rules") { - Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?, - None => view_rules(s), - }; - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let id = s.catalog.alloc_album_id(); - let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) }; - s.commit("New Smart Album", Op::AddAlbum { album })?; - Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) - } - ), - cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| { - use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for}; - Ok(json!(FIELDS - .iter() - .map(|(id, label, kind)| { - let k = match kind { - Kind::Text => "text", - Kind::Keywords => "keywords", - Kind::Number => "number", - Kind::Album => "album", - Kind::Date => "date", - Kind::Choice(_) => "choice", - Kind::Bool => "bool", - }; - let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); - if let Kind::Choice(c) = kind { - v["choices"] = json!(c.iter().map(|c| c.0).collect::>()); - v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::>().into(); - } - v - }) - .collect::>())) - }), - cmd!( - "album.setRules", - "Edit Smart Album", - [], - None, - "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}", - always, - |s, p| { - let id = album_param(p, "id", "album.setRules")?; - let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; - let rules = if bool_or(p, "fromView", false) { - // the view is saved as it is, but never as a loop (showing A, which tests this - // album, and saving that view here would make it test itself) - let view = view_rules(s); - let loops: Vec = view - .rule_set - .as_ref() - .map(|r| r.check_for(&s.catalog, Some(id))) - .unwrap_or_default() - .into_iter() - .chain(s.catalog.album_filter_problem(&view, Some(id))) - .filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop) - .map(|p| p.to_string()) - .collect(); - if !loops.is_empty() { - return Err(bad("album.setRules", loops.join("; "))); - } - view - } else { - let replace = bool_or(p, "replace", false); - let folder = cur.library_folder.clone(); - let base = if replace { Default::default() } else { cur }; - let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?; - // the rules dialog has no folder field: replacing its rules keeps the folder - // unless the call says (`libraryFolder: null`) to drop it - if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { - r.library_folder = folder; - } - r - }; - let set = Op::SetAlbumRules { id, rules: Box::new(rules) }; - // a new name goes with the rules: one step, applied (and undone) together - let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n)); - match rename { - Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?, - None => s.commit("Edit Smart Album", set)?, - }; - Ok(json!({"count": s.catalog.album_count(id)})) - } - ), - cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| { - let id = album_param(p, "id", "album.rename")?; - let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string(); - s.commit("Rename Album", Op::RenameAlbum { id, name })?; - ok() - }), - cmd!( - "album.delete", - "Delete Album", - [], - None, - "{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step", - always, - |s, p| { - let id = album_param(p, "id", "album.delete")?; - let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder; - let mut children = std::collections::BTreeMap::>::new(); - for album in s.catalog.albums() { - if let Some(parent) = album.parent { - children.entry(parent).or_default().push(album.id); - } - } - // Iterative traversal: even a malformed cycle is rejected before any change. - // Reverse parent-first order removes children first; the batch inverse restores - // parents first, including memberships, smart rules, covers and manual ordering. - let mut pending = vec![id]; - let mut order = Vec::new(); - let mut removed = std::collections::BTreeSet::new(); - while let Some(next) = pending.pop() { - if !removed.insert(next) { - return Err(bad("album.delete", "cycle in album folders")); - } - order.push(next); - if let Some(nested) = children.remove(&next) { - pending.extend(nested); - } - } - let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect(); - s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?; - if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) { - s.source = LibrarySource::All; - } - ok() - } - ), - cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| { - let id = album_param(p, "id", "album.move")?; - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - s.commit("Move Album", Op::MoveAlbum { id, parent })?; - ok() - }), - cmd!( - "album.reorder", - "Reorder Album", - [], - None, - "{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)", - always, - |s, p| { - let id = album_param(p, "id", "album.reorder")?; - let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?; - let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent }; - let before = opt_album_param(p, "before", "album.reorder")?; - if before == Some(id) { - // before itself: where it is - return Ok(json!({"changed": 0})); - } - let mut order: Vec = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect(); - let at = match before { - Some(b) => { - let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?; - if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) { - return Err(bad("album.reorder", "folders are placed among folders and albums among albums")); - } - at - } - None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(), - None => order.len(), - }; - order.insert(at.min(order.len()), id); - let moved = me.parent != parent; - let mut ops = Vec::new(); - if moved { - ops.push(Op::MoveAlbum { id, parent }); - } - for (i, aid) in order.iter().enumerate() { - let want = u32::try_from(i).ok(); - // a moved album starts without a place in its new folder - let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) }; - if have != want { - ops.push(Op::SetAlbumOrder { id: *aid, order: want }); - } - } - if ops.is_empty() { - return Ok(json!({"changed": 0})); - } - let changed = ops.len(); - s.commit("Reorder Album", Op::Batch { ops })?; - Ok(json!({"changed": changed})) - } - ), - cmd!( - "album.sort", - "Sort Albums by Name", - [], - None, - "{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place", - always, - |s, p| { - let parent = opt_album_param(p, "parent", "album.sort")?; - let ops: Vec = s - .catalog - .album_children(parent) - .into_iter() - .filter(|a| a.order.is_some()) - .map(|a| Op::SetAlbumOrder { id: a.id, order: None }) - .collect(); - if ops.is_empty() { - return Ok(json!({"changed": 0})); - } - let changed = ops.len(); - s.commit("Sort Albums by Name", Op::Batch { ops })?; - Ok(json!({"changed": changed})) - } - ), - cmd!( - "album.addPhotos", - "Add to Album", - ["Photo"], - None, - "{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error", - has_library_selection, - |s, p| { - let id = album_param(p, "id", "album.addPhotos")?; - let targets = super::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected()); - let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; - if al.is_smart() || al.folder { - return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); - } - let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; - let mut photos = al.photos.clone(); - let before = photos.len(); - // looked up, not searched for: adding thousands to a big album stays linear - let mut held: std::collections::HashSet = photos.iter().copied().collect(); - for t in targets { - if held.insert(t) { - photos.push(t); - } - } - let added = photos.len() - before; - let cover = al.cover.or(photos.first().copied()); - s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; - Ok(json!({"added": added, "skipped": skipped})) - } - ), - cmd!( - "album.toggleTarget", - "Add to Target Album", - [], - None, - "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", - has_library_selection, - |s, p| { - let targets = - album_targets_or_err(s, "album.toggleTarget", super::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?; - let (targets, skipped) = targets; - let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); - let id = match target.or_else(|| s.catalog.quick_collection()) { - Some(id) => id, - None => { - let id = s.catalog.alloc_album_id(); - let album = Album { quick: true, ..Album::new(id, "Quick Collection") }; - s.commit("New Quick Collection", Op::AddAlbum { album })?; - id - } - }; - let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - // looked up, not searched for: thousands toggled in a big album stay linear - let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); - let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); - let photos: Vec = if all_in { - let targets: std::collections::HashSet = targets.iter().copied().collect(); - al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() - } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() - }; - let name = al.name.clone(); - let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); - s.commit( - if all_in { "Remove from Target Album" } else { "Add to Target Album" }, - Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] }, - )?; - Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped})) - } - ), - cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| { - s.target_album = match p.get("id").and_then(Value::as_u64) { - Some(a) => { - let id = AlbumId(a); - let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?; - if al.is_smart() || al.folder { - return Err(bad("album.setTarget", "smart albums and folders can't hold photos")); - } - Some(id) - } - None => None, - }; - Ok(json!({"target": s.target_album.map(|a| a.0)})) - }), - cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| { - let Some(id) = s.catalog.quick_collection() else { return ok() }; - s.commit( - "Clear Quick Collection", - Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] }, - )?; - ok() - }), - cmd!( - "library.autoImport", - "Auto Import Settings", - [], - None, - "{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings", - always, - |s, p| { - if let Some(f) = p.get("folder") { - s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) { - Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()), - Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))), - None => None, - }; - } - if let Some(c) = p.get("copy").and_then(Value::as_bool) { - s.import_defaults.auto_copy = c; - } - if let Some(a) = p.get("album") { - s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string); - } - s.save_prefs()?; - let d = &s.import_defaults; - Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album})) - } - ), - cmd!( - "library.autoImportScan", - "Auto Import Now", - [], - None, - "{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}", - always, - |s, p| { - let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) }; - // only files that stopped growing: a file still being written is left for later - let known: std::collections::HashSet = s - .catalog - .photos() - .filter_map(|p| match &p.source { - lightcraft_catalog::Source::File { path } => Some(path.clone()), - _ => None, - }) - .collect(); - // the folder's files and sizes: listed here, or already listed on a worker thread (the app) - let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) { - Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(), - None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?, - }; - let mut fresh = Vec::new(); - for (ps, size) in listing { - if known.contains(&ps) { - continue; - } - // each file is tried once (a non-photo isn't retried every scan) - if s.auto_import_seen.get(&ps) == Some(&u64::MAX) { - continue; - } - let seen = s.auto_import_seen.insert(ps.clone(), size); - if size > 0 && seen == Some(size) { - fresh.push(ps); - } - } - if fresh.is_empty() { - return Ok(json!({"imported": [], "folder": folder})); - } - let mode = if s.import_defaults.auto_copy { "copy" } else { "add" }; - // a photo deleted from the library doesn't come back on its own because its file - // is still in the watched folder (a copy's source is not a known path) - let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"}); - if let Some(a) = s.import_defaults.auto_album.clone() { - match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) { - Some(al) => params["album"] = json!(al.id.0), - None => params["albumName"] = json!(a), - } - } - for f in &fresh { - s.auto_import_seen.insert(f.clone(), u64::MAX); - } - if p.get("start").and_then(Value::as_bool) == Some(false) { - // the caller imports them (the app: on a worker thread) - return Ok(json!({"imported": [], "folder": folder, "import": params})); - } - let sel = s.selection.clone(); - let r = s.execute("library.import", ¶ms)?; - // arriving photos don't take over the selection - s.selection = sel; - let mut result = json!({"imported": r["imported"], "folder": folder}); - if let Some(warnings) = r.get("warnings") { - result["warnings"] = warnings.clone(); - } - Ok(result) - } - ), - cmd!( - "album.removePhotos", - "Remove from Album", - ["Photo"], - None, - "{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}", - has_selection, - |s, p| { - let id = match p.get("id") { - Some(_) => album_param(p, "id", "album.removePhotos")?, - None => match s.source { - LibrarySource::Album(a) => a, - _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), - }, - }; - let targets = super::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected()); - let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; - if al.is_smart() { - return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); - } - if al.folder { - return Err(bad("album.removePhotos", "album folders don't hold photos")); - } - let name = al.name.clone(); - let targets: std::collections::HashSet = targets.into_iter().collect(); - let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); - let removed = al.photos.len().saturating_sub(photos.len()); - if removed > 0 { - s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; - } - Ok(json!({"removed": removed, "album": name})) - } - ), - cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| { - let id = album_param(p, "id", "album.setCover")?; - let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active()); - s.commit("Set Album Cover", Op::SetAlbumCover { id, cover: photo })?; - ok() - }), - // ---- import - cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None, - "{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required", - always, |_, p| { - let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; - let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; - Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped})) - }), - cmd!( - "library.importLightroom", - "Import Lightroom Catalog", - [], - None, - "{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.", - always, - |s, p| { - let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?; - crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false)) - } - ), - cmd!( - query "library.importPreview", - "Review Import", - [], - None, - "{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added", - always, - |s, p| { - let paths = strs(p, "paths"); - if paths.is_empty() { - return Err(bad("library.importPreview", "no paths")); - } - let c = crate::import::scan(s, &paths); - let dups = c.iter().filter(|c| c.duplicate.is_some()).count(); - Ok(json!({"scanned": c.len(), "duplicates": dups, "candidates": c})) - } - ), - cmd!( - "library.import", - "Import Photos", - ["File"], - Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", - always, - |s, p| { - let req = import_params(s, p)?; - if req.paths.is_empty() { - return Err(bad("library.import", "no paths")); - } - let undo0 = s.undo.len(); - let report = crate::import::import_with(s, &req.paths, &req.opts)?; - let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?; - // one undo step for the whole import - let ids_of = |k: &str| -> Vec { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() }; - let (imported, restored) = (ids_of("imported"), ids_of("restored")); - let n = s.undo.len().saturating_sub(undo0); - let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) }; - s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" })); - let imported = if imported.is_empty() { restored } else { imported }; - if let Some(f) = imported.first() { - s.selection = Selection::single(PhotoId(*f)); - } - Ok(report) - } - ), - // ---- persistence - cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| { - let photos = s.catalog.len(); - let albums = s.catalog.albums().count(); - let (rendered_n, rendered_bytes) = s.media.rendered.mem_usage(); - let (sources_n, sources_bytes) = s.media.source_usage(); - let disk = s.media.rendered.disk().map(|d| { - use std::sync::atomic::Ordering::Relaxed; - json!({ - "path": d.dir().display().to_string(), - "bytes": d.size(), - "hits": d.hits.load(Relaxed), - "misses": d.misses.load(Relaxed), - "writes": d.writes.load(Relaxed), - }) - }); - let cache = json!({ - "renderedInMemory": rendered_n, "renderedBytes": rendered_bytes, - "sourcesInMemory": sources_n, "sourceBytes": sources_bytes, - "disk": disk, - }); - let Some(lib) = &s.library else { - return Ok(json!({"persistent": false, "photos": photos, "albums": albums, "cache": cache})); - }; - let j = lib.journal(); - Ok(json!({ - "persistent": true, - "path": lib.dir.display().to_string(), - "photos": photos, - "albums": albums, - "seq": j.seq(), - "snapshotSeq": j.snapshot_seq(), - "logRecords": j.log_records(), - "logBytes": j.log_bytes(), - "lastError": lib.last_error, - // settings files that were unreadable or damaged at open (kept, defaults used) - "settingsWarnings": lib.settings_warnings, - // changes applied in memory whose write failed (retried by every save) - "unsavedOps": s.unsaved().map_or(0, |u| u.0), - "unsavedError": s.unsaved().map(|u| u.1), - // untouched Local records forgotten when the library opened - "forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})), - "persistence": j.stats(), - "cache": cache, - "load": { - "created": lib.report.created, - "replayed": lib.report.replayed, - "tornBytes": lib.report.torn_bytes, - "damaged": lib.report.damaged, - }, - })) - }), - cmd!("library.compact", "Optimize Library", ["File"], None, "{}", has_library, |s, _| { - s.compact_library()?; - ok() - }), - cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| { - s.media.rendered.clear(); - s.media.clear_sources(); - ok() - }), - ] -} - -/// `base` with a partial Filter (JSON) merged on top. -/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see -/// [`merge_rules`]). -fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { - let mut v = serde_json::to_value(base).unwrap_or_default(); - lightcraft_develop::presets::deep_merge(&mut v, patch); - let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; - if let Some(rules) = f.rule_set.as_mut() { - rules.upgrade(); - } - Ok(f) -} - -/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`; -/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a -/// problem too). -fn merge_rules( - base: &lightcraft_catalog::Filter, - patch: &Value, - c: &str, - cat: &lightcraft_catalog::Catalog, - owner: Option, -) -> Result { - let f = merge_filter(base, patch, c)?; - // only rules this change sets are checked: one that stopped checking since (its album was - // deleted) doesn't block editing the album's other settings - let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null()); - let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); - // and a loop through the album field the change sets - if patch.get("album").is_some_and(|a| !a.is_null()) { - problems.extend(cat.album_filter_problem(&f, owner)); - } - if !problems.is_empty() { - return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); - } - Ok(f) -} - -/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup -/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of -/// one disk but a way to reach several. -pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { - ids.iter() - .filter_map(|id| s.catalog.photo(*id)) - .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) - .any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v)) -} - -/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from -/// its rules with the filter bar's settings on top. -fn view_rules(s: &Session) -> lightcraft_catalog::Filter { - use lightcraft_catalog::Filter; - let smart = match s.source { - LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()), - _ => None, - }; - match smart { - Some(base) => { - // overlay the fields the filter bar changed - let cur = serde_json::to_value(&s.filter).unwrap_or_default(); - let def = serde_json::to_value(Filter::default()).unwrap_or_default(); - let mut patch = serde_json::Map::new(); - if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) { - for (k, v) in c { - if d.get(k) != Some(v) { - patch.insert(k.clone(), v.clone()); - } - } - } - // the view as it is: a rule that no longer checks (its album was deleted) just matches - // nothing there, and mustn't cost the filter bar's settings - merge_filter(&base, &Value::Object(patch), "").unwrap_or(base) - } - None => { - let mut f = s.source.to_filter(&s.filter, &s.catalog); - f.deleted = false; - if s.source == LibrarySource::LibraryFolder { - f.library_folder = s.library_folder.clone(); - } - f - } - } -} - -impl Session { - /// The current view (source + filter) as smart-album rules (see `album.createSmart`). - pub fn view_rules(&self) -> lightcraft_catalog::Filter { - view_rules(self) - } -} - -fn has_library(s: &Session) -> std::result::Result<(), String> { - if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) } -} - -/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W", -/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs). -pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { - // typographic minus signs and dashes mean a negative value, like ASCII '-' - let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-"); - let t = t.trim(); - // split into the two coordinates: at a comma, else after the first N/S hemisphere letter - let (a, b) = match t.split_once(',') { - Some((a, b)) => (a.trim().to_string(), b.trim().to_string()), - None => { - let lead = t.starts_with(['N', 'S', 'n', 's']); - let i = if lead { - // hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter - t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i) - } else { - t.find(['N', 'S', 'n', 's']).map(|i| i + 1) - } - .or_else(|| t.find(char::is_whitespace))?; - (t[..i].trim().to_string(), t[i..].trim().to_string()) - } - }; - let one = |s: &str, pos: char, neg: char| -> Option { - let up = s.to_ascii_uppercase(); - let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 }; - let nums: Vec = - up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::().ok()).collect::>()?; - let v = match nums.as_slice() { - [d] => *d, - [d, m] => d + m / 60.0, - [d, m, s] => d + m / 60.0 + s / 3600.0, - _ => return None, - }; - let _ = pos; - Some(sign * v) - }; - let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?); - (la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo)) -} - -#[cfg(test)] -mod gps_tests { - #[test] - fn coordinates_people_type() { - let p = super::parse_gps; - assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276))); - let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap(); - assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); - let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); - assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); - assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1))); - assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276))); - assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093))); - assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093))); - assert_eq!(p("95, 10"), None); - assert_eq!(p("hello"), None); - } -} +pub(crate) use filters::covers_other_disks; +pub use import::{ImportRequest, import_batch_done, import_params, list_auto_import_folder}; +pub use specs::specs; #[cfg(test)] #[path = "library/tests_relative_ratings.rs"] diff --git a/crates/engine/src/cmd/library/filters.rs b/crates/engine/src/cmd/library/filters.rs new file mode 100644 index 000000000..1eaa5223f --- /dev/null +++ b/crates/engine/src/cmd/library/filters.rs @@ -0,0 +1,92 @@ +use super::*; + +/// `base` with a partial Filter (JSON) merged on top. +/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see +/// [`merge_rules`]). +fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { + let mut v = serde_json::to_value(base).unwrap_or_default(); + lightcraft_develop::presets::deep_merge(&mut v, patch); + let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } + Ok(f) +} + +/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`; +/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a +/// problem too). +pub(super) fn merge_rules( + base: &lightcraft_catalog::Filter, + patch: &Value, + c: &str, + cat: &lightcraft_catalog::Catalog, + owner: Option, +) -> Result { + let f = merge_filter(base, patch, c)?; + // only rules this change sets are checked: one that stopped checking since (its album was + // deleted) doesn't block editing the album's other settings + let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null()); + let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); + // and a loop through the album field the change sets + if patch.get("album").is_some_and(|a| !a.is_null()) { + problems.extend(cat.album_filter_problem(&f, owner)); + } + if !problems.is_empty() { + return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); + } + Ok(f) +} + +/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup +/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of +/// one disk but a way to reach several. +pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { + ids.iter() + .filter_map(|id| s.catalog.photo(*id)) + .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) + .any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v)) +} + +/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from +/// its rules with the filter bar's settings on top. +pub(super) fn view_rules(s: &Session) -> lightcraft_catalog::Filter { + use lightcraft_catalog::Filter; + let smart = match s.source { + LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()), + _ => None, + }; + match smart { + Some(base) => { + // overlay the fields the filter bar changed + let cur = serde_json::to_value(&s.filter).unwrap_or_default(); + let def = serde_json::to_value(Filter::default()).unwrap_or_default(); + let mut patch = serde_json::Map::new(); + if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) { + for (k, v) in c { + if d.get(k) != Some(v) { + patch.insert(k.clone(), v.clone()); + } + } + } + // the view as it is: a rule that no longer checks (its album was deleted) just matches + // nothing there, and mustn't cost the filter bar's settings + merge_filter(&base, &Value::Object(patch), "").unwrap_or(base) + } + None => { + let mut f = s.source.to_filter(&s.filter, &s.catalog); + f.deleted = false; + if s.source == LibrarySource::LibraryFolder { + f.library_folder = s.library_folder.clone(); + } + f + } + } +} + +impl Session { + /// The current view (source + filter) as smart-album rules (see `album.createSmart`). + pub fn view_rules(&self) -> lightcraft_catalog::Filter { + view_rules(self) + } +} diff --git a/crates/engine/src/cmd/library/gps.rs b/crates/engine/src/cmd/library/gps.rs new file mode 100644 index 000000000..44f0e02c5 --- /dev/null +++ b/crates/engine/src/cmd/library/gps.rs @@ -0,0 +1,57 @@ +/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W", +/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs). +pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { + // typographic minus signs and dashes mean a negative value, like ASCII '-' + let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-"); + let t = t.trim(); + // split into the two coordinates: at a comma, else after the first N/S hemisphere letter + let (a, b) = match t.split_once(',') { + Some((a, b)) => (a.trim().to_string(), b.trim().to_string()), + None => { + let lead = t.starts_with(['N', 'S', 'n', 's']); + let i = if lead { + // hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter + t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i) + } else { + t.find(['N', 'S', 'n', 's']).map(|i| i + 1) + } + .or_else(|| t.find(char::is_whitespace))?; + (t[..i].trim().to_string(), t[i..].trim().to_string()) + } + }; + let one = |s: &str, pos: char, neg: char| -> Option { + let up = s.to_ascii_uppercase(); + let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 }; + let nums: Vec = + up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::().ok()).collect::>()?; + let v = match nums.as_slice() { + [d] => *d, + [d, m] => d + m / 60.0, + [d, m, s] => d + m / 60.0 + s / 3600.0, + _ => return None, + }; + let _ = pos; + Some(sign * v) + }; + let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?); + (la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo)) +} + +#[cfg(test)] +mod gps_tests { + #[test] + fn coordinates_people_type() { + let p = super::parse_gps; + assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276))); + let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap(); + assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); + let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); + assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); + assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1))); + assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276))); + assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093))); + assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093))); + assert_eq!(p("95, 10"), None); + assert_eq!(p("hello"), None); + } +} diff --git a/crates/engine/src/cmd/library/import.rs b/crates/engine/src/cmd/library/import.rs new file mode 100644 index 000000000..81b82671f --- /dev/null +++ b/crates/engine/src/cmd/library/import.rs @@ -0,0 +1,110 @@ +use super::selection::strs; +use super::*; + +/// The auto-import folder's visible files with their sizes (the file-system half of +/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). +pub fn list_auto_import_folder(folder: &str) -> std::result::Result, String> { + let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?; + Ok(rd + .flatten() + .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) + .filter_map(|e| { + let m = std::fs::metadata(e.path()).ok()?; + m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len())) + }) + .collect()) +} + +/// `library.import`'s params, checked (no file-system calls). +pub struct ImportRequest { + pub paths: Vec, + pub opts: crate::import::ImportOptions, + pub album: Option, + pub album_name: Option, +} + +/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a +/// worker thread, with the same options). +pub fn import_params(s: &Session, p: &Value) -> Result { + let paths = strs(p, "paths"); + let mode = match str_param(p, "mode").unwrap_or("add") { + "add" => crate::import::ImportMode::Add, + "copy" => crate::import::ImportMode::Copy, + "move" => crate::import::ImportMode::Move, + other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))), + }; + let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) { + Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?), + None => None, + }; + let album = p.get("album").and_then(Value::as_u64); + if let Some(a) = album + && s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart()) + { + return Err(bad("library.import", "album must be a regular album")); + } + let organize = match str_param(p, "organize") { + Some(o) => crate::import::Organize::parse(o).ok_or_else(|| { + bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})")) + })?, + None => Default::default(), + }; + if let crate::import::Organize::Template(t) = &organize + && let Some(e) = crate::rename::folder_template_error(t) + { + return Err(bad("library.import", e)); + } + let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + if let Some(n) = &metadata_preset + && !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) + { + return Err(bad("library.import", format!("unknown metadata preset `{n}`"))); + } + let on_deleted = match str_param(p, "onDeleted") { + Some(v) => { + crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))? + } + None => Default::default(), + }; + let opts = crate::import::ImportOptions { + on_deleted, + mode, + preset, + keywords: strs(p, "keywords"), + destination: str_param(p, "destination").map(str::to_string), + organize, + rename: str_param(p, "rename").map(str::to_string), + rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize, + metadata_preset, + convert_dng: bool_or(p, "dng", false), + local: bool_or(p, "local", false), + }; + let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + Ok(ImportRequest { paths, opts, album, album_name }) +} + +/// After a batch of an import was committed: the album (a new one named `album_name` when there +/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with +/// `album`). +pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option, album_name: Option<&str>) -> Result { + let mut report = serde_json::to_value(report).unwrap_or_default(); + // photos an import restored from Recently Deleted take part like new ones (album, selection) + let imported: Vec = + ["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect(); + if album.is_none() + && let Some(name) = album_name + && !imported.is_empty() + { + let r = s.execute("album.create", &json!({"name": name}))?; + album = r["id"].as_u64(); + } + if let Some(a) = album + && !imported.is_empty() + { + // (album.addPhotos wants a selection: the new photos are about to be it) + s.selection = Selection::single(PhotoId(imported[0])); + s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?; + report["album"] = json!(a); + } + Ok(report) +} diff --git a/crates/engine/src/cmd/library/selection.rs b/crates/engine/src/cmd/library/selection.rs new file mode 100644 index 000000000..1ed1868d7 --- /dev/null +++ b/crates/engine/src/cmd/library/selection.rs @@ -0,0 +1,247 @@ +use super::*; + +/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). +/// Its callers end an open edit (a slider drag) before their own change, as moving to the next +/// photo does, so it is its own undo step and comes first. +pub(super) fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { + let at = before.iter().position(|id| Some(*id) == s.selection.active); + let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); + s.selection.ids.retain(|id| remaining.contains(id)); + s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); + if s.selection.active.is_none() + && let Some(at) = at + { + // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. + let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); + s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); + } +} + +pub(super) fn album_param(p: &Value, key: &str, c: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`"))) +} + +/// An optional album id: absent or `null` is `None`, anything but a number is an error. +pub(super) fn opt_album_param(p: &Value, key: &str, c: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))), + } +} + +pub(super) fn strs(p: &Value, key: &str) -> Vec { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() +} + +/// The targets an album can hold and how many were browsed (Local) photos, which album views +/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members. +pub(super) fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) { + let before = targets.len(); + let kept: Vec = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect(); + let skipped = before - kept.len(); + (kept, skipped) +} + +/// Album targets, or the error for a selection that was nothing but Local photos. +pub(super) fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Result<(Vec, usize)> { + let (kept, skipped) = album_targets(s, targets); + if kept.is_empty() && skipped > 0 { + return Err(bad(command, crate::cmd::LOCAL_ONLY)); + } + Ok((kept, skipped)) +} + +/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep +/// showing `i/n` (issue #1493). +pub(super) fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result { + let mut ops: Vec = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); + let n = ops.len(); + if n == 0 { + return Ok(0); + } + ops.extend(s.catalog.remove_from_stacks_ops(ids)); + s.commit(label, Op::Batch { ops })?; + Ok(n) +} + +/// Apply one op per target as a single undo step. +pub(super) fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { + let targets = s.targets(p)?; + let ops: Vec = targets.iter().filter_map(|id| f(*id)).collect(); + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(json!({"changed": n})) +} + +/// Adjust each target's own rating; unchanged bounds do not create an undo step. +pub(super) fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result { + let mut ops = Vec::new(); + for id in s.targets(p)? { + let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; + let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; + if rating != old { + ops.push(Op::SetRating { id, rating }); + } + } + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(json!({"changed": n})) +} + +/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged, +/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step. +pub(super) fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result { + let mut ops = Vec::new(); + for id in s.targets(p)? { + let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag; + let flag = step(old); + if flag != old { + ops.push(Op::SetFlag { id, flag }); + } + } + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(json!({"changed": n})) +} + +/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar, +/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is +/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so +/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey +/// keep showing the photo they rate. +pub(super) fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result) -> Result { + let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null())); + // the view as it was, then an open drag ends first, as its own undo step (the selection may + // move on; the drag itself can take the photo out of the view) + let before = (!named).then(|| s.visible_shared().1); + if !named { + s.end_interaction()?; + } + let active = s.selection.active; + let v = edit(s)?; + if let Some(before) = before + && active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a)) + { + select_after_deletion(s, &before); + s.active_mask = None; + s.active_spot = None; + } else { + advance_if(s, p); + } + Ok(v) +} + +fn advance_if(s: &mut Session, p: &Value) { + if bool_or(p, "advance", false) { + let _ = step(s, 1); + } +} + +pub(super) fn step(s: &mut Session, d: isize) -> Result { + let vis = s.visible_cloned(); + if vis.is_empty() { + return ok(); + } + let cur = s.selection.active.and_then(|a| vis.iter().position(|x| *x == a)); + let next = match cur { + Some(i) => (i as isize + d).clamp(0, vis.len() as isize - 1) as usize, + None => 0, + }; + s.end_interaction()?; + s.selection = Selection::single(vis[next]); + s.active_mask = None; + s.active_spot = None; + Ok(json!({"active": vis[next].0})) +} + +/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input +/// can't overflow). +pub(super) fn row_step(p: &Value) -> isize { + p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize +} + +pub(super) fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { + let targets = s.targets(p)?; + let mut ops = Vec::new(); + for id in targets { + if let Some(d) = s.develop_of(id) { + let mut d = (*d).clone(); + d.orientation = d.orientation.rotated(cw); + // the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo) + let turn = lightcraft_geom::Orientation::Normal.rotated(cw); + d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect); + d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w)); + ops.extend(s.develop_op(id, d, "Rotate")); + } + } + let n = ops.len(); + s.commit(if cw { "Rotate Right" } else { "Rotate Left" }, Op::Batch { ops })?; + Ok(json!({"changed": n})) +} + +pub(super) fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { + let targets = s.targets(p)?; + let mut ops = Vec::new(); + for id in targets { + if let Some(d) = s.develop_of(id) { + let mut d = (*d).clone(); + if horizontal { + d.crop.flip_h = !d.crop.flip_h; + } else { + d.crop.flip_v = !d.crop.flip_v; + } + ops.extend(s.develop_op(id, d, "Flip")); + } + } + let n = ops.len(); + s.commit("Flip", Op::Batch { ops })?; + Ok(json!({"changed": n})) +} + +/// `library.selectBy`: select the photos in view that match every given criterion. +pub(super) fn select_by(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.selectBy"; + let flag: Option = match p.get("flag") { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(C, "flag is pick, reject or none"))?), + None => None, + }; + let rating = p.get("rating").and_then(Value::as_u64).map(|r| r.min(5) as u8); + let op = p.get("ratingOp").and_then(Value::as_str).unwrap_or("gte"); + let label: Option> = match p.get("label").and_then(Value::as_str) { + Some("none") => Some(None), + Some(l) => Some(Some(serde_json::from_value(json!(l)).map_err(|_| bad(C, format!("unknown label `{l}`")))?)), + None => None, + }; + if flag.is_none() && rating.is_none() && label.is_none() { + return Err(bad(C, "give flag, rating and/or label")); + } + let matches = |ph: &lightcraft_catalog::Photo| { + flag.is_none_or(|f| ph.flag == f) + && rating.is_none_or(|r| match op { + "eq" => ph.rating == r, + "lte" => ph.rating <= r, + _ => ph.rating >= r, + }) + && label.is_none_or(|l| ph.label == l) + }; + let mut ids: Vec = if bool_or(p, "add", false) { s.selection.ids.clone() } else { Vec::new() }; + for id in s.visible_cloned() { + if !ids.contains(&id) && s.catalog.photo(id).is_some_and(|ph| matches(ph)) { + ids.push(id); + } + } + let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied()); + let n = ids.len(); + s.selection = Selection { ids, active }; + Ok(json!({"selected": n})) +} + +pub(super) fn has_library(s: &Session) -> std::result::Result<(), String> { + if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) } +} diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs new file mode 100644 index 000000000..07ab6f221 --- /dev/null +++ b/crates/engine/src/cmd/library/specs.rs @@ -0,0 +1,1374 @@ +use super::filters::{covers_other_disks, merge_rules, view_rules}; +use super::gps::parse_gps; +use super::import::{import_batch_done, import_params, list_auto_import_folder}; +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::*; + +/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. +const MAX_SEED: u64 = 1 << 53; + +/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error. +fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { + match p.get("seed") { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")), + } +} + +/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles +/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON +/// clients that read numbers as doubles (web, MCP agents) get the same seed back. +fn next_seed(prev: u64, now: &str) -> u64 { + let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b))); + (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1) +} + +pub fn specs() -> Vec { + vec![ + // ---- view source / filter / sort + cmd!( + "library.source", + "Show Source", + [], + None, + "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}", + always, + |s, p| { + let kind = str_param(p, "kind").unwrap_or("all"); + s.source = match kind { + "all" => LibrarySource::All, + "recentlyAdded" => LibrarySource::RecentlyAdded, + "recentlyDeleted" => LibrarySource::RecentlyDeleted, + "picks" => LibrarySource::Picks, + "missing" => LibrarySource::Missing, + "album" => { + let a = album_param(p, "id", "library.source")?; + if s.catalog.album(a).is_none_or(|a| a.folder) { + return Err(bad("library.source", "no such album")); + } + // one album at a time: `to_filter` makes the album shown replace the filter's, and the + // filter bar has no album chip, so a left-over one would narrow the next source unseen + s.filter.album = None; + LibrarySource::Album(a) + } + "libraryFolder" => { + let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty()); + let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?; + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it")); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad("library.source", format!("{path}: no photo in the library was imported from it"))); + } + if covers_other_disks(s, path, &ids) { + return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one"))); + } + s.library_folder = Some(path.to_string()); + // one folder at a time: a folder filter left over would be ignored + s.filter.library_folder = None; + LibrarySource::LibraryFolder + } + other => return Err(bad("library.source", format!("unknown source `{other}`"))), + }; + s.keep_active_in_view(); + Ok(json!({"count": s.visible().len()})) + } + ), + cmd!( + "library.filter", + "Filter", + [], + None, + "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}", + always, + |s, p| { + let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); + lightcraft_develop::presets::deep_merge(&mut v, p); + let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; + let mut f = f; + // a blank folder is no folder: no hidden "filters active" state + f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty()); + if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { + return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); + } + // checked like a smart album's rules when this call sets them: an unknown field or a value + // that can't match is an error, not an empty grid. Rules that stopped checking since + // (their album was deleted) don't block other filter changes. + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } + let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null()); + if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { + return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); + } + s.filter = f; + s.shelved_filter = None; + Ok(json!({"count": s.visible().len()})) + } + ), + cmd!( + query "library.folders", + "Library Folders", + [], + None, + "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", + always, + |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) + ), + cmd!( + "library.removeFolder", + "Remove Folder from Library", + [], + None, + "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}", + always, + |s, p| { + const C: &str = "library.removeFolder"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + if lightcraft_catalog::query::folder_key(path).is_empty() { + return Err(bad(C, format!("{path}: not a folder"))); + } + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad(C, "choose a folder, not the whole startup disk")); + } + if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) { + return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it"))); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { + return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); + } + mark_deleted(s, &ids, "Remove Folder from Library")?; + if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.filter.library_folder = None; + } + // a filter put aside by Enable Filters must not bring the removed folder back + if let Some(f) = s.shelved_filter.as_mut() + && f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) + { + f.library_folder = None; + } + if s.shelved_filter.as_ref() == Some(&Filter::default()) { + s.shelved_filter = None; + } + // the folder being shown is gone: back to everything, as for a deleted album + if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.library_folder = None; + if s.source == LibrarySource::LibraryFolder { + s.source = LibrarySource::All; + } + } + let vis = s.visible_cloned(); + s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); + Ok(json!({"removed": ids.len()})) + } + ), + cmd!( + "folder.label", + "Set Folder Color Label", + [], + None, + "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}", + always, + |s, p| { + const C: &str = "folder.label"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? { + "none" => None, + x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?), + }; + // taking a label off always works, also once the folder's photos are gone + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + if label.is_none() && s.catalog.folder_record(path).is_none() { + return Ok(json!({"path": path, "label": label})); + } + let op = s.catalog.folder_label_op(path, label); + s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?; + Ok(json!({"path": path, "label": label})) + } + ), + cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { + s.filter = Default::default(); + s.shelved_filter = None; + Ok(json!({"count": s.visible().len()})) + }), + // Classic: Library > Enable Filters (Cmd+L) switches the filter off and on again as it was + cmd!( + "library.toggleFilters", + "Enable Filters", + ["View"], + Some("Cmd+L"), + "{} — with a filter set, puts it aside and shows everything; with one put aside, brings it back as it was (stars, flag, text… together). Setting or clearing a filter while it is off forgets the one put aside → {enabled, count}", + always, + |s, _| { + if s.filter != Filter::default() { + s.shelved_filter = Some(std::mem::take(&mut s.filter)); + } else if let Some(mut f) = s.shelved_filter.take() { + // one folder at a time: the folder shown now wins over the one the filter had + if s.source == LibrarySource::LibraryFolder { + f.library_folder = None; + } + s.filter = f; + } + Ok(json!({"enabled": s.shelved_filter.is_none(), "count": s.visible().len()})) + } + ), + cmd!( + "library.sort", + "Sort", + ["View", "Sort"], + None, + "{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}", + always, + |s, p| { + let key: SortKey = match p.get("key") { + Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?, + None => s.sort.key, + }; + let group = match str_param(p, "group") { + Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?, + None => s.sort.group, + }; + let mut seed = seed_param(p, "library.sort", s.sort.seed)?; + let explicit = p.get("seed").is_some_and(|v| !v.is_null()); + if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit { + // switching to Random is a fresh shuffle, not whatever seed was left behind + seed = next_seed(seed, &(s.clock)()); + } + s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed }; + ok() + } + ), + cmd!( + "library.refresh", + "Refresh View", + ["View"], + None, + "{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed", + always, + |s, _| { + s.refresh_view(); + ok() + } + ), + cmd!( + "library.holdView", + "Hold View Until Refreshed", + ["View"], + None, + "{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing", + always, + |s, p| { + let on = match p.get("on") { + None | Some(Value::Null) => !s.hold_view, + Some(Value::Bool(on)) => *on, + Some(_) => return Err(bad("library.holdView", "on must be true or false")), + }; + if on != s.hold_view { + let was = s.hold_view; + s.hold_view = on; + if let Err(e) = s.save_prefs() { + // not kept: not changed either, so asking again does what was asked + s.hold_view = was; + return Err(e); + } + s.refresh_view(); + } + Ok(json!({"hold": s.hold_view})) + } + ), + cmd!( + "library.shuffle", + "Reshuffle", + ["View", "Sort"], + None, + "{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time", + always, + |s, p| { + let seed = match seed_param(p, "library.shuffle", s.sort.seed)? { + given if p.get("seed").is_some_and(|v| !v.is_null()) => given, + // derived from the previous seed and the session clock: reproducible under a test + // clock, and no RNG needed + prev => next_seed(prev, &(s.clock)()), + }; + s.sort = Sort { key: SortKey::Random, seed, ..s.sort }; + ok() + } + ), + cmd!( + query "library.groups", + "Date Groups", + [], + None, + "{by?: day|month|year (default: the sort's grouping; auto = day)} → [{key, label, start, count}] date headers of the grid", + always, + |s, p| { + let by = match str_param(p, "by") { + Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.groups", "by must be auto|none|day|month|year"))?, + None => s.sort.group, + }; + let vis = s.visible_cloned(); + Ok(serde_json::to_value(s.catalog.date_runs(&vis, s.sort.key, by)).unwrap_or_default()) + } + ), + // ---- selection + cmd!( + "library.select", + "Select Photos", + [], + None, + "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library", + always, + |s, p| { + let ids = crate::cmd::photo_ids(p, "library.select")?.unwrap_or_default(); + let active = p.get("active").and_then(Value::as_u64).map(PhotoId); + let mode = str_param(p, "mode").unwrap_or("replace"); + // An id that is not in the library is an error, and the selection stays as it was: + // selecting it would otherwise report success and leave the session with no usable + // active photo (#182). + if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) { + return Err(bad("library.select", format!("no such photo {}", id.0))); + } + s.end_interaction()?; + match mode { + "replace" => { + s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) }; + } + "add" => { + for id in ids { + if !s.selection.contains(id) { + s.selection.ids.push(id); + } + s.selection.active = Some(id); + } + } + "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), + "range" => { + let vis = s.visible_cloned(); + if let Some(id) = ids.first() { + s.selection.extend_to(*id, &vis); + } + } + other => return Err(bad("library.select", format!("unknown mode `{other}`"))), + } + s.active_mask = None; + s.active_spot = None; + Ok(json!({"selected": s.selection.ids.len()})) + } + ), + cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { + let vis = s.visible_cloned(); + s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; + Ok(json!({"selected": s.selection.ids.len()})) + }), + cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { + Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default()) + }), + cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], Some("Cmd+Alt+Shift+C"), "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| { + let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; + let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta; + let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description, + "copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url, + "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country, + "keywords": m.keywords}); + s.meta_clipboard = Some(v.clone()); + Ok(v) + }), + cmd!( + "photo.pasteMetadata", + "Paste Metadata", + ["Photo"], + Some("Cmd+Alt+Shift+V"), + "{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}", + has_selection, + |s, p| { + let c = "photo.pasteMetadata"; + let clip = s.meta_clipboard.clone().ok_or_else(|| bad(c, "nothing copied (photo.copyMetadata)"))?; + let fields: Option> = + p.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let mut params = serde_json::Map::new(); + for (k, v) in clip.as_object().into_iter().flatten() { + if fields.as_ref().is_none_or(|f| f.iter().any(|x| x == k)) { + params.insert(k.clone(), v.clone()); + } + } + if let Some(ids) = p.get("ids") { + params.insert("ids".into(), ids.clone()); + } + s.execute("photo.setMeta", &Value::Object(params)) + } + ), + cmd!( + "library.selectBy", + "Select by Flag, Rating or Label", + [], + None, + "{flag?: pick|reject|none, rating?: 0..5, ratingOp?: gte|eq|lte (default gte), label?: red|…|none, add?: bool (extend the selection)} — among the photos in view", + always, + select_by + ), + cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { + let a = s.selection.active; + s.selection = Selection { ids: a.into_iter().collect(), active: a }; + ok() + }), + cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)), + cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)), + cmd!( + "library.down", + "Photo Below", + [], + Some("Down"), + "{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}", + always, + |s, p| { step(s, row_step(p)) } + ), + cmd!( + "library.up", + "Photo Above", + [], + Some("Up"), + "{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}", + always, + |s, p| { step(s, -row_step(p)) } + ), + // ---- rating / flags / labels + cmd!( + "photo.rate", + "Set Rating", + ["Photo", "Set Rating"], + None, + "{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let r = crate::cmd::index_req(p, "rating", "photo.rate") + .ok() + .filter(|r| *r <= 5) + .ok_or_else(|| bad("photo.rate", "rating must be a whole number 0..5"))?; + cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))) + } + ), + cmd!( + "photo.decreaseRating", + "Decrease Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) } + ), + cmd!( + "photo.increaseRating", + "Increase Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) } + ), + cmd!( + "photo.flag", + "Set Flag", + ["Photo", "Set Flag"], + None, + "{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; + cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))) + } + ), + // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) + cmd!( + "photo.cycleFlag", + "Cycle Flag", + ["Photo", "Set Flag"], + Some("`"), + "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + cull(s, p, |s| { + change_flag(s, p, "Cycle Flag", |f| match f { + Flag::None => Flag::Pick, + Flag::Pick => Flag::Reject, + Flag::Reject => Flag::None, + }) + }) + } + ), + cmd!( + "photo.increaseFlag", + "Increase Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Up"), + "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) } + ), + cmd!( + "photo.decreaseFlag", + "Decrease Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Down"), + "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) } + ), + cmd!( + "photo.pick", + "Flag as Pick", + ["Photo", "Set Flag"], + Some("P"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) } + ), + cmd!( + "photo.reject", + "Flag as Reject", + ["Photo", "Set Flag"], + Some("X"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) } + ), + cmd!( + "photo.unflag", + "Unflag", + ["Photo", "Set Flag"], + Some("U"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) } + ), + cmd!( + "photo.label", + "Set Color Label", + ["Photo", "Set Color Label"], + None, + "{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear + let l = match p.get("label") { + Some(Value::Null) => None, + Some(Value::String(x)) if x == "none" => None, + Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?), + None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")), + Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")), + }; + cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))) + } + ), + // ---- orientation + cmd!("photo.rotateLeft", "Rotate Left", ["Photo"], Some("Cmd+["), "{ids?}", has_selection, |s, p| rotate(s, p, false)), + cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)), + cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)), + cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)), + // ---- face / pet regions + cmd!( + "photo.removeRegion", + "Remove Face Box", + [], + None, + "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", + always, + |s, p| { + let id = + crate::cmd::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?; + let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone(); + if index >= meta.regions.len() { + return Err(bad("photo.removeRegion", "no such region")); + } + let gone = meta.regions.remove(index); + s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"removed": gone.name})) + } + ), + cmd!( + "photo.setRegion", + "Resize Face Box", + [], + None, + "{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar", + always, + |s, p| { + const C: &str = "photo.setRegion"; + // a drag is one undo step however many frames it took: the caller commits once, on release + let id = crate::cmd::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad(C, "missing or invalid `index`"))?; + let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?; + let num = |k: &str| { + r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number"))) + }; + let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?); + let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0)); + let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0)); + if x1 - x0 < 0.005 || y1 - y0 < 0.005 { + return Err(bad(C, "the box would be too small")); + } + let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone(); + let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?; + region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 }; + s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}})) + } + ), + // ---- delete / restore + cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { + let before = s.visible_cloned(); + let targets = s.targets(p)?; + // an open drag ends first, as its own undo step (the selection moves on) + s.end_interaction()?; + let n = mark_deleted(s, &targets, "Delete")?; + select_after_deletion(s, &before); + Ok(json!({"changed": n})) + }), + cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { + id, + deleted: false + }))), + cmd!( + "library.emptyRecentlyDeleted", + "Empty Recently Deleted", + ["Photo"], + None, + "{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)", + always, + |s, _| { + let ids: Vec = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); + if ids.is_empty() { + return Ok(json!({"deleted": 0})); + } + let op = s.catalog.delete_photos_permanently_ops(&ids); + s.commit("Empty Recently Deleted", op)?; + s.selection = Selection::default(); + Ok(json!({"deleted": ids.len()})) + } + ), + cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { + let before = s.visible_cloned(); + let t = s.targets(p)?; + // before the inverse ops are taken, so undo brings the photo back with its edit + s.end_interaction()?; + let op = s.catalog.delete_photos_permanently_ops(&t); + s.commit("Delete Permanently", op)?; + select_after_deletion(s, &before); + Ok(json!({"deleted": t.len()})) + }), + // ---- metadata + cmd!( + "photo.setMeta", + "Edit Info", + [], + None, + "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", + has_selection, + |s, p| { + let strs = + |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::>()); + let targets = s.targets(p)?; + // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear + let gps: Option> = match p.get("gps") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(t)) if t.trim().is_empty() => Some(None), + Some(Value::String(t)) => Some(Some( + parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?, + )), + Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) { + (Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))), + _ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")), + }, + Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")), + }; + let status = match str_param(p, "copyrightStatus") { + Some(t) => Some( + CopyrightStatus::parse(t) + .ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?, + ), + None => None, + }; + let mut ops = Vec::new(); + for id in targets { + let Some(ph) = s.catalog.photo(id) else { continue }; + let mut m = ph.meta.clone(); + if let Some(g) = gps { + m.gps = g; + } + if let Some(st) = status { + m.copyright_status = st; + } + for (k, field) in [ + ("title", &mut m.title), + ("caption", &mut m.caption), + ("copyright", &mut m.copyright), + ("usageTerms", &mut m.usage_terms), + ("copyrightUrl", &mut m.copyright_url), + ("creator", &mut m.creator), + ("location", &mut m.location), + ("city", &mut m.city), + ("state", &mut m.state), + ("country", &mut m.country), + ("altText", &mut m.alt_text), + ("extendedDescription", &mut m.extended_description), + ] { + if let Some(v) = str_param(p, k) { + *field = v.to_string(); + } + } + // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case + use lightcraft_catalog::keywords::{clean, same}; + let add = |m: &mut lightcraft_catalog::Meta, k: &str| { + let k = clean(k); + if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) { + m.keywords.push(k); + } + }; + if let Some(k) = strs("keywords") { + m.keywords.clear(); + for k in k { + add(&mut m, &k); + } + } + for k in strs("addKeywords").unwrap_or_default() { + add(&mut m, &k); + } + for k in strs("removeKeywords").unwrap_or_default() { + let k = clean(&k); + m.keywords.retain(|x| !same(&clean(x), &k)); + } + ops.push(Op::SetMeta { id, meta: Box::new(m) }); + } + let n = ops.len(); + s.commit("Edit Info", Op::Batch { ops })?; + // keywords just added become the Recent Keywords set + let added: Vec = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect(); + if n > 0 && !added.is_empty() { + crate::cmd::keywords::note_recent(s, &added); + } + Ok(json!({"changed": n})) + } + ), + // ---- albums + cmd!( + "album.create", + "New Album", + ["File"], + None, + "{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)", + always, + |s, p| { + let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.create", "empty name")); + } + let folder = bool_or(p, "folder", false); + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; + let id = s.catalog.alloc_album_id(); + let cover = photos.first().copied(); + s.commit( + if folder { "New Folder" } else { "New Album" }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, + )?; + Ok(json!({"id": id.0, "skipped": skipped})) + } + ), + cmd!( + "album.createSmart", + "New Smart Album…", + [], + None, + "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)", + always, + |s, p| { + let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.createSmart", "empty name")); + } + let rules = match p.get("rules") { + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?, + None => view_rules(s), + }; + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let id = s.catalog.alloc_album_id(); + let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) }; + s.commit("New Smart Album", Op::AddAlbum { album })?; + Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) + } + ), + cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| { + use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for}; + Ok(json!(FIELDS + .iter() + .map(|(id, label, kind)| { + let k = match kind { + Kind::Text => "text", + Kind::Keywords => "keywords", + Kind::Number => "number", + Kind::Album => "album", + Kind::Date => "date", + Kind::Choice(_) => "choice", + Kind::Bool => "bool", + }; + let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); + if let Kind::Choice(c) = kind { + v["choices"] = json!(c.iter().map(|c| c.0).collect::>()); + v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::>().into(); + } + v + }) + .collect::>())) + }), + cmd!( + "album.setRules", + "Edit Smart Album", + [], + None, + "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}", + always, + |s, p| { + let id = album_param(p, "id", "album.setRules")?; + let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; + let rules = if bool_or(p, "fromView", false) { + // the view is saved as it is, but never as a loop (showing A, which tests this + // album, and saving that view here would make it test itself) + let view = view_rules(s); + let loops: Vec = view + .rule_set + .as_ref() + .map(|r| r.check_for(&s.catalog, Some(id))) + .unwrap_or_default() + .into_iter() + .chain(s.catalog.album_filter_problem(&view, Some(id))) + .filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop) + .map(|p| p.to_string()) + .collect(); + if !loops.is_empty() { + return Err(bad("album.setRules", loops.join("; "))); + } + view + } else { + let replace = bool_or(p, "replace", false); + let folder = cur.library_folder.clone(); + let base = if replace { Default::default() } else { cur }; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?; + // the rules dialog has no folder field: replacing its rules keeps the folder + // unless the call says (`libraryFolder: null`) to drop it + if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { + r.library_folder = folder; + } + r + }; + let set = Op::SetAlbumRules { id, rules: Box::new(rules) }; + // a new name goes with the rules: one step, applied (and undone) together + let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n)); + match rename { + Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?, + None => s.commit("Edit Smart Album", set)?, + }; + Ok(json!({"count": s.catalog.album_count(id)})) + } + ), + cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| { + let id = album_param(p, "id", "album.rename")?; + let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string(); + s.commit("Rename Album", Op::RenameAlbum { id, name })?; + ok() + }), + cmd!( + "album.delete", + "Delete Album", + [], + None, + "{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step", + always, + |s, p| { + let id = album_param(p, "id", "album.delete")?; + let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder; + let mut children = std::collections::BTreeMap::>::new(); + for album in s.catalog.albums() { + if let Some(parent) = album.parent { + children.entry(parent).or_default().push(album.id); + } + } + // Iterative traversal: even a malformed cycle is rejected before any change. + // Reverse parent-first order removes children first; the batch inverse restores + // parents first, including memberships, smart rules, covers and manual ordering. + let mut pending = vec![id]; + let mut order = Vec::new(); + let mut removed = std::collections::BTreeSet::new(); + while let Some(next) = pending.pop() { + if !removed.insert(next) { + return Err(bad("album.delete", "cycle in album folders")); + } + order.push(next); + if let Some(nested) = children.remove(&next) { + pending.extend(nested); + } + } + let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect(); + s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?; + if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) { + s.source = LibrarySource::All; + } + ok() + } + ), + cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| { + let id = album_param(p, "id", "album.move")?; + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + s.commit("Move Album", Op::MoveAlbum { id, parent })?; + ok() + }), + cmd!( + "album.reorder", + "Reorder Album", + [], + None, + "{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)", + always, + |s, p| { + let id = album_param(p, "id", "album.reorder")?; + let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?; + let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent }; + let before = opt_album_param(p, "before", "album.reorder")?; + if before == Some(id) { + // before itself: where it is + return Ok(json!({"changed": 0})); + } + let mut order: Vec = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect(); + let at = match before { + Some(b) => { + let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?; + if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) { + return Err(bad("album.reorder", "folders are placed among folders and albums among albums")); + } + at + } + None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(), + None => order.len(), + }; + order.insert(at.min(order.len()), id); + let moved = me.parent != parent; + let mut ops = Vec::new(); + if moved { + ops.push(Op::MoveAlbum { id, parent }); + } + for (i, aid) in order.iter().enumerate() { + let want = u32::try_from(i).ok(); + // a moved album starts without a place in its new folder + let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) }; + if have != want { + ops.push(Op::SetAlbumOrder { id: *aid, order: want }); + } + } + if ops.is_empty() { + return Ok(json!({"changed": 0})); + } + let changed = ops.len(); + s.commit("Reorder Album", Op::Batch { ops })?; + Ok(json!({"changed": changed})) + } + ), + cmd!( + "album.sort", + "Sort Albums by Name", + [], + None, + "{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place", + always, + |s, p| { + let parent = opt_album_param(p, "parent", "album.sort")?; + let ops: Vec = s + .catalog + .album_children(parent) + .into_iter() + .filter(|a| a.order.is_some()) + .map(|a| Op::SetAlbumOrder { id: a.id, order: None }) + .collect(); + if ops.is_empty() { + return Ok(json!({"changed": 0})); + } + let changed = ops.len(); + s.commit("Sort Albums by Name", Op::Batch { ops })?; + Ok(json!({"changed": changed})) + } + ), + cmd!( + "album.addPhotos", + "Add to Album", + ["Photo"], + None, + "{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error", + has_library_selection, + |s, p| { + let id = album_param(p, "id", "album.addPhotos")?; + let targets = crate::cmd::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected()); + let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); + } + let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; + let mut photos = al.photos.clone(); + let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet = photos.iter().copied().collect(); + for t in targets { + if held.insert(t) { + photos.push(t); + } + } + let added = photos.len() - before; + let cover = al.cover.or(photos.first().copied()); + s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; + Ok(json!({"added": added, "skipped": skipped})) + } + ), + cmd!( + "album.toggleTarget", + "Add to Target Album", + [], + None, + "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", + has_library_selection, + |s, p| { + let targets = + album_targets_or_err(s, "album.toggleTarget", crate::cmd::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?; + let (targets, skipped) = targets; + let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); + let id = match target.or_else(|| s.catalog.quick_collection()) { + Some(id) => id, + None => { + let id = s.catalog.alloc_album_id(); + let album = Album { quick: true, ..Album::new(id, "Quick Collection") }; + s.commit("New Quick Collection", Op::AddAlbum { album })?; + id + } + }; + let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); + let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); + let photos: Vec = if all_in { + let targets: std::collections::HashSet = targets.iter().copied().collect(); + al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() + } else { + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() + }; + let name = al.name.clone(); + let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); + s.commit( + if all_in { "Remove from Target Album" } else { "Add to Target Album" }, + Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] }, + )?; + Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped})) + } + ), + cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| { + s.target_album = match p.get("id").and_then(Value::as_u64) { + Some(a) => { + let id = AlbumId(a); + let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.setTarget", "smart albums and folders can't hold photos")); + } + Some(id) + } + None => None, + }; + Ok(json!({"target": s.target_album.map(|a| a.0)})) + }), + cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| { + let Some(id) = s.catalog.quick_collection() else { return ok() }; + s.commit( + "Clear Quick Collection", + Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] }, + )?; + ok() + }), + cmd!( + "library.autoImport", + "Auto Import Settings", + [], + None, + "{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings", + always, + |s, p| { + if let Some(f) = p.get("folder") { + s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) { + Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()), + Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))), + None => None, + }; + } + if let Some(c) = p.get("copy").and_then(Value::as_bool) { + s.import_defaults.auto_copy = c; + } + if let Some(a) = p.get("album") { + s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string); + } + s.save_prefs()?; + let d = &s.import_defaults; + Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album})) + } + ), + cmd!( + "library.autoImportScan", + "Auto Import Now", + [], + None, + "{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}", + always, + |s, p| { + let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) }; + // only files that stopped growing: a file still being written is left for later + let known: std::collections::HashSet = s + .catalog + .photos() + .filter_map(|p| match &p.source { + lightcraft_catalog::Source::File { path } => Some(path.clone()), + _ => None, + }) + .collect(); + // the folder's files and sizes: listed here, or already listed on a worker thread (the app) + let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) { + Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(), + None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?, + }; + let mut fresh = Vec::new(); + for (ps, size) in listing { + if known.contains(&ps) { + continue; + } + // each file is tried once (a non-photo isn't retried every scan) + if s.auto_import_seen.get(&ps) == Some(&u64::MAX) { + continue; + } + let seen = s.auto_import_seen.insert(ps.clone(), size); + if size > 0 && seen == Some(size) { + fresh.push(ps); + } + } + if fresh.is_empty() { + return Ok(json!({"imported": [], "folder": folder})); + } + let mode = if s.import_defaults.auto_copy { "copy" } else { "add" }; + // a photo deleted from the library doesn't come back on its own because its file + // is still in the watched folder (a copy's source is not a known path) + let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"}); + if let Some(a) = s.import_defaults.auto_album.clone() { + match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) { + Some(al) => params["album"] = json!(al.id.0), + None => params["albumName"] = json!(a), + } + } + for f in &fresh { + s.auto_import_seen.insert(f.clone(), u64::MAX); + } + if p.get("start").and_then(Value::as_bool) == Some(false) { + // the caller imports them (the app: on a worker thread) + return Ok(json!({"imported": [], "folder": folder, "import": params})); + } + let sel = s.selection.clone(); + let r = s.execute("library.import", ¶ms)?; + // arriving photos don't take over the selection + s.selection = sel; + let mut result = json!({"imported": r["imported"], "folder": folder}); + if let Some(warnings) = r.get("warnings") { + result["warnings"] = warnings.clone(); + } + Ok(result) + } + ), + cmd!( + "album.removePhotos", + "Remove from Album", + ["Photo"], + None, + "{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}", + has_selection, + |s, p| { + let id = match p.get("id") { + Some(_) => album_param(p, "id", "album.removePhotos")?, + None => match s.source { + LibrarySource::Album(a) => a, + _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), + }, + }; + let targets = crate::cmd::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected()); + let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; + if al.is_smart() { + return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + } + if al.folder { + return Err(bad("album.removePhotos", "album folders don't hold photos")); + } + let name = al.name.clone(); + let targets: std::collections::HashSet = targets.into_iter().collect(); + let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); + let removed = al.photos.len().saturating_sub(photos.len()); + if removed > 0 { + s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; + } + Ok(json!({"removed": removed, "album": name})) + } + ), + cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| { + let id = album_param(p, "id", "album.setCover")?; + let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active()); + s.commit("Set Album Cover", Op::SetAlbumCover { id, cover: photo })?; + ok() + }), + // ---- import + cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None, + "{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required", + always, |_, p| { + let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; + let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; + Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped})) + }), + cmd!( + "library.importLightroom", + "Import Lightroom Catalog", + [], + None, + "{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.", + always, + |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?; + crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false)) + } + ), + cmd!( + query "library.importPreview", + "Review Import", + [], + None, + "{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added", + always, + |s, p| { + let paths = strs(p, "paths"); + if paths.is_empty() { + return Err(bad("library.importPreview", "no paths")); + } + let c = crate::import::scan(s, &paths); + let dups = c.iter().filter(|c| c.duplicate.is_some()).count(); + Ok(json!({"scanned": c.len(), "duplicates": dups, "candidates": c})) + } + ), + cmd!( + "library.import", + "Import Photos", + ["File"], + Some("Cmd+Shift+I"), + "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", + always, + |s, p| { + let req = import_params(s, p)?; + if req.paths.is_empty() { + return Err(bad("library.import", "no paths")); + } + let undo0 = s.undo.len(); + let report = crate::import::import_with(s, &req.paths, &req.opts)?; + let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?; + // one undo step for the whole import + let ids_of = |k: &str| -> Vec { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() }; + let (imported, restored) = (ids_of("imported"), ids_of("restored")); + let n = s.undo.len().saturating_sub(undo0); + let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) }; + s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" })); + let imported = if imported.is_empty() { restored } else { imported }; + if let Some(f) = imported.first() { + s.selection = Selection::single(PhotoId(*f)); + } + Ok(report) + } + ), + // ---- persistence + cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| { + let photos = s.catalog.len(); + let albums = s.catalog.albums().count(); + let (rendered_n, rendered_bytes) = s.media.rendered.mem_usage(); + let (sources_n, sources_bytes) = s.media.source_usage(); + let disk = s.media.rendered.disk().map(|d| { + use std::sync::atomic::Ordering::Relaxed; + json!({ + "path": d.dir().display().to_string(), + "bytes": d.size(), + "hits": d.hits.load(Relaxed), + "misses": d.misses.load(Relaxed), + "writes": d.writes.load(Relaxed), + }) + }); + let cache = json!({ + "renderedInMemory": rendered_n, "renderedBytes": rendered_bytes, + "sourcesInMemory": sources_n, "sourceBytes": sources_bytes, + "disk": disk, + }); + let Some(lib) = &s.library else { + return Ok(json!({"persistent": false, "photos": photos, "albums": albums, "cache": cache})); + }; + let j = lib.journal(); + Ok(json!({ + "persistent": true, + "path": lib.dir.display().to_string(), + "photos": photos, + "albums": albums, + "seq": j.seq(), + "snapshotSeq": j.snapshot_seq(), + "logRecords": j.log_records(), + "logBytes": j.log_bytes(), + "lastError": lib.last_error, + // settings files that were unreadable or damaged at open (kept, defaults used) + "settingsWarnings": lib.settings_warnings, + // changes applied in memory whose write failed (retried by every save) + "unsavedOps": s.unsaved().map_or(0, |u| u.0), + "unsavedError": s.unsaved().map(|u| u.1), + // untouched Local records forgotten when the library opened + "forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})), + "persistence": j.stats(), + "cache": cache, + "load": { + "created": lib.report.created, + "replayed": lib.report.replayed, + "tornBytes": lib.report.torn_bytes, + "damaged": lib.report.damaged, + }, + })) + }), + cmd!("library.compact", "Optimize Library", ["File"], None, "{}", has_library, |s, _| { + s.compact_library()?; + ok() + }), + cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| { + s.media.rendered.clear(); + s.media.clear_sources(); + ok() + }), + ] +} diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index bef292f8f..e1b33cc71 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -31,6 +31,10 @@ pub struct PreviewBuild { pub what: &'static str, /// Why the whole build failed (e.g. the smart previews folder isn't there). pub error: std::sync::Mutex>, + /// Test-only: the gate this build's lanes wait at before a photo's first render (see + /// [`test_gate`]). Captured by `build` on the calling thread; production builds never have one. + #[cfg(test)] + pub(crate) gate: Option>, } impl PreviewBuild { @@ -79,10 +83,15 @@ fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard { /// Render one photo's jobs in order. The source the first job decodes serves the ones after it /// (the grid thumbnail is made from the view's source instead of decoding the file again). -pub(crate) fn run_photo(jobs: Vec) -> bool { +/// `None` when `cancel` was asked for before a job started: that photo is not counted as done. +fn run_photo_cancellable(jobs: Vec, cancel: Option<&AtomicBool>) -> Option { let mut decoded: Option = None; let mut ok = true; for mut j in jobs { + // between a photo's own previews too (a cancelled 1:1 view skips its grid thumbnail) + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return None; + } if let Some(src) = &decoded && !matches!(j.source, crate::media::SourceRef::Loaded(_)) { @@ -94,7 +103,13 @@ pub(crate) fn run_photo(jobs: Vec) -> bool { decoded = r.loaded; } } - ok + Some(ok) +} + +/// [`run_photo_cancellable`] for callers that render a photo without a cancel flag. +#[cfg(test)] +pub(crate) fn run_photo(jobs: Vec) -> bool { + run_photo_cancellable(jobs, None) == Some(true) } /// How many photos render side by side: as many as an export batch for standard previews (each @@ -105,6 +120,112 @@ fn lanes(n: usize, full: bool) -> usize { if full { l.min(2) } else { l } } +/// A test-only rendezvous at the start of a photo's jobs, so a cancel can be delivered at a known +/// point instead of racing a render. One gate belongs to the build `build` starts on the test +/// thread that installed it ([`install`]); it is never global and production builds have none. +#[cfg(test)] +pub(crate) mod test_gate { + use std::sync::{Arc, Condvar, Mutex, PoisonError}; + use std::time::{Duration, Instant}; + + /// The longest a lane waits for a test to open the gate: if a test ends without its + /// [`ReleaseOnDrop`] running, the build still finishes instead of stranding its worker. + const MAX_HOLD: Duration = Duration::from_secs(30); + /// The longest a test waits for a lane to reach the gate. + const MAX_ENTER_WAIT: Duration = Duration::from_secs(10); + + #[derive(Default)] + struct State { + entered: usize, + released: bool, + } + + /// The gate a test's preview build waits at before rendering a photo (see `install`). + #[derive(Default)] + pub(crate) struct PreviewGate { + state: Mutex, + opened: Condvar, + } + + impl std::fmt::Debug for PreviewGate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreviewGate").finish_non_exhaustive() + } + } + + impl PreviewGate { + pub(crate) fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Called by a lane about to render a photo's first job; returns once the test opens the + /// gate, or after `MAX_HOLD` so a lost test cannot strand the worker. + pub(crate) fn enter(&self) { + let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner); + st.entered = st.entered.saturating_add(1); + self.opened.notify_all(); + let deadline = Instant::now() + MAX_HOLD; + while !st.released { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let (next, _) = self.opened.wait_timeout(st, remaining).unwrap_or_else(|e| e.into_inner()); + st = next; + } + } + + /// Wait (at most `MAX_ENTER_WAIT`) until at least `n` lanes have reached the gate. + pub(crate) fn wait_entered(&self, n: usize) -> bool { + let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner); + let deadline = Instant::now() + MAX_ENTER_WAIT; + loop { + if st.entered >= n { + return true; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + let (next, _) = self.opened.wait_timeout(st, remaining).unwrap_or_else(|e| e.into_inner()); + st = next; + } + } + + /// Open the gate: every lane waiting, or arriving later, passes without waiting. + pub(crate) fn release(&self) { + let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner); + st.released = true; + self.opened.notify_all(); + } + } + + /// Opens the gate when dropped, wherever a test ends (assertion failure, panic, early return). + pub(crate) struct ReleaseOnDrop(pub(crate) Arc); + + impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + self.0.release(); + } + } + + thread_local! { + /// The gate for the next preview build started on this thread, taken by `build` so it is + /// per invocation (never shared by two builds or two tests). + static NEXT_GATE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + } + + /// Have the next preview build started on this thread wait at `gate` before its first photo. + pub(crate) fn install(gate: Arc) { + NEXT_GATE.with(|g| *g.borrow_mut() = Some(gate)); + } + + /// Take the gate installed for this build, leaving none for later builds. + pub(crate) fn take() -> Option> { + NEXT_GATE.with(|g| g.borrow_mut().take()) + } +} + fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) { let total = jobs.len(); let queue = std::sync::Mutex::new(jobs.into_iter()); @@ -115,7 +236,17 @@ fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard, la } let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); let Some(photo_jobs) = next else { break }; - let ok = crate::guard::catch("building previews", || run_photo(photo_jobs)).unwrap_or(false); + // A cancel accepted while this lane waited for the queue (or for another lane's + // photo) stops before this photo's first render; a cancelled photo is not counted. + if state.cancel.load(Ordering::Relaxed) { + break; + } + #[cfg(test)] + if let Some(gate) = &state.gate { + gate.enter(); + } + let outcome = crate::guard::catch("building previews", || run_photo_cancellable(photo_jobs, Some(&state.cancel))).unwrap_or(Some(false)); + let Some(ok) = outcome else { break }; if !ok { state.failed.fetch_add(1, Ordering::Relaxed); } @@ -143,6 +274,9 @@ fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard, la fn build(s: &mut Session, p: &Value) -> Result { const C: &str = "library.buildPreviews"; + // tests: this invocation's gate, consumed here so an early error cannot attach it to a later build + #[cfg(test)] + let gate = test_gate::take(); let edge = match str_param(p, "size").unwrap_or("standard") { "standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)), "full" | "1:1" => None, @@ -154,6 +288,9 @@ fn build(s: &mut Session, p: &Value) -> Result { // explicit ids, else the selection, else everything in view let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let jobs: Vec> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); + #[cfg(test)] + let state = Arc::new(PreviewBuild { total: jobs.len(), gate, ..Default::default() }); + #[cfg(not(test))] let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); let task = task_for(s, &state); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 26f5d9bc4..1390ca167 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -8,12 +8,12 @@ //! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset` //! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear` //! * `print.exportPdf {path, ids?}` -//! * `print.template.list|save|apply|delete` +//! * `print.template.list|save|apply|delete`, `print.template.export|import` (`.lcprint` files) use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, f64_req}; use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size}; -use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name}; +use crate::print::template::{FILE_EXT, MAX_FILE, MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name, unique_name}; use crate::{Result, Session}; fn rect_json(r: &Rect) -> Value { @@ -277,10 +277,21 @@ fn template_name(p: &Value, id: &str) -> Result { clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e)) } +/// Save the preferences after a change to the templates; if that fails, the templates go back to +/// `before`, so the list matches what is saved and a retry does the same thing. +fn save_templates(s: &mut Session, before: Vec) -> Result<()> { + if let Err(e) = s.save_prefs() { + s.print_templates = before; + return Err(e); + } + Ok(()) +} + fn template_save(s: &mut Session, p: &Value) -> Result { const ID: &str = "print.template.save"; let name = template_name(p, ID)?; let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?; + let before = s.print_templates.clone(); match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { Some(x) => *x = t, None => { @@ -290,7 +301,7 @@ fn template_save(s: &mut Session, p: &Value) -> Result { s.print_templates.push(t); } } - s.save_prefs()?; + save_templates(s, before)?; Ok(template_list(s)) } @@ -309,15 +320,66 @@ fn template_apply(s: &mut Session, p: &Value) -> Result { fn template_delete(s: &mut Session, p: &Value) -> Result { const ID: &str = "print.template.delete"; let name = template_name(p, ID)?; - let before = s.print_templates.len(); + let before = s.print_templates.clone(); s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name)); - if s.print_templates.len() == before { + if s.print_templates.len() == before.len() { return Err(bad(ID, format!("no print template `{name}`"))); } - s.save_prefs()?; + save_templates(s, before)?; Ok(template_list(s)) } +fn template_path<'a>(p: &'a Value, id: &str) -> Result<&'a str> { + p.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(id, "missing `path`")) +} + +fn template_export(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.export"; + let name = template_name(p, ID)?; + let mut path = std::path::PathBuf::from(template_path(p, ID)?); + if path.extension().is_none() { + path.set_extension(FILE_EXT); + } + let t = s + .print_templates + .iter() + .find(|x| x.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?; + let bytes = serde_json::to_vec_pretty(&t.to_file()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + let name = t.name.clone(); + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad(ID, e))?; + crate::export::write_file(&target, &bytes).map_err(|e| bad(ID, e))?; + Ok(json!({"name": name, "path": target})) +} + +fn template_import(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.import"; + let path = std::path::Path::new(template_path(p, ID)?); + let meta = std::fs::metadata(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(bad(ID, format!("{}: not a file", path.display()))); + } + if meta.len() > MAX_FILE { + return Err(bad(ID, format!("{}: {} bytes is too large for a print template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024))); + } + let bytes = std::fs::read(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + let mut t = PrintTemplate::from_file(&bytes).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + if s.print_templates.len() >= MAX_TEMPLATES { + return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first"))); + } + let name = unique_name(&t.name, &s.print_templates); + let renamed = name != t.name; + t.name = name.clone(); + let before = s.print_templates.clone(); + s.print_templates.push(t); + save_templates(s, before)?; + let mut out = template_list(s); + out["name"] = json!(name); + out["renamed"] = json!(renamed); + Ok(out) +} + pub fn specs() -> Vec { vec![ cmd!(query "print.get", "Print Settings", [], None, @@ -349,5 +411,11 @@ pub fn specs() -> Vec { always, template_save), cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply), cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete), + cmd!(query "print.template.export", "Export Print Template", [], None, + "{name, path}: writes the template as a .lcprint JSON file ({format, version, name, settings}; `.lcprint` is added when the path has no extension); replaces an existing file, never an original → {name, path}", + always, template_export), + cmd!(query "print.template.import", "Import Print Template", [], None, + "{path: a .lcprint file (at most 256 KB)}: checked like a saved template (format, version, name, every setting; cell photos and guides are dropped) and added under its name; a name taken (any case) gets \" (2)\"… → print.template.list + {name, renamed}", + always, template_import), ] } diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 33ff76ec0..191cee953 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -379,7 +379,7 @@ pub fn specs() -> Vec { "Slideshow Playback Settings", [], None, - "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due), manual?: bool (manual slideshow: a slide stays up until ← or → changes it)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual}", always, playback ), diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 093f3a84f..b5d2c856c 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -552,360 +552,4 @@ pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial}; - - /// A hand-written sidecar in attribute form (as many tools write it). - const SIDECAR: &str = r#" - - - - 0, 064, 52192, 205255, 255 - - - 0, 0255, 255 - - - -"#; - - fn props(x: &str) -> Props { - lightcraft_meta::parse_xmp(x).unwrap().properties - } - - #[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_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); - assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0)); - assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0)); - assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0)); - assert_eq!(s.mixer.orange.hue, -7.0); - assert_eq!(s.mixer.blue.sat, -22.0); - assert_eq!(s.mixer.aqua.lum, 13.0); - assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); - assert_eq!(s.curve.master.len(), 4); - 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"); - 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)); - 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)); - assert_eq!(s.geometry.upright, Upright::Level); - assert_eq!(s.geometry.vertical, -10.0); - assert!(s.optics.remove_ca); - assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); - let cal = s.calibration; - assert_eq!( - (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), - (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) - ); - let c = s.crop.geometry; - assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); - } - - /// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve: - /// it is left out, rather than stored with a point the curve editor can't place. - #[test] - fn curves_with_non_finite_points_are_left_out() { - for bad in ["NaN, 0", "64, inf", "-inf, 3"] { - let x = SIDECAR.replace("64, 52", &format!("{bad}")); - let partial = to_partial(&props(&x), Target::RawAbsolute); - let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); - assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master); - assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite())); - } - } - - #[test] - fn only_present_fields_are_in_the_partial() { - let x = r#""#; - let partial = to_partial(&props(x), Target::Any); - assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); - } - - #[test] - fn hdr_edit_mode_turns_hdr_editing_on_or_off() { - let x = |v: &str| { - format!( - r#""# - ) - }; - let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); - assert!(on.hdr.enabled); - assert_eq!(on.light.exposure, 1.5); - assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); - let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); - assert!(true_form.hdr.enabled); - let mut hdr = DevelopSettings::default(); - hdr.hdr.enabled = true; - assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); - // without the field nothing about HDR changes - let plain = r#""#; - assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); - } - - #[test] - fn element_form_bw_and_incremental_wb() { - let x = r#" - - True - -35 - 25 - -6 - 4800 - "#; - let p = props(x); - let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); - assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); - assert_eq!(rendered.bw_mix.blue, -35.0); - assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); - assert_eq!(rendered.wb.tint, -6.0); - let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); - assert_eq!(raw.wb.temp, 4800.0); - } - - #[test] - fn named_wb_and_already_applied() { - let x = r#""#; - let p = props(x); - assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); - assert!(!has_adjustments(&p)); - let bookkeeping = r#""#; - assert!(!has_adjustments(&props(bookkeeping))); - } - - #[test] - fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { - // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white - // of 3650 K / −2 (written for this test from the numbers in the issue). - let x = r#""#; - let p = props(x); - let rel = to_partial(&p, Target::RawRelative); - // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale - let t = rel["wb"]["temp"].as_f64().unwrap(); - assert!((t - 6275.0).abs() < 5.0, "{t}"); - assert_eq!(rel["wb"]["tint"], -3.0); - assert_eq!(rel["wb"]["mode"], "custom"); - // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured - // illuminant (and a preset) keeps the Kelvin value - assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); - assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); - // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); - assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); - assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); - // the as-shot fields are a reference, not an adjustment: nothing to report - assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); - // no tint next to the as-shot tint: the custom tint is taken as the shift itself - let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); - assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); - } - - #[test] - fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { - // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: - // 3578 K read on the relative scale would be a large blue shift (issue #510) - let x = r#""#; - let p = props(x); - let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); - assert_eq!(partial["wb"], json!({"mode": "asShot"})); - assert_eq!(partial["light"]["exposure"], 0.3); - assert_eq!(unmapped.len(), 1, "{unmapped:?}"); - assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); - // a raw with a measured illuminant and a rendered file read it as before, and report nothing - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); - assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); - assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); - // an unusable reference (zero) counts as none - let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); - assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); - } - - #[test] - fn target_follows_the_file() { - use lightcraft_catalog::MediaKind; - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); - // Lightroom's `fileFormat` name for proprietary raws - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); - // a raw shown from its embedded preview develops like a rendered file - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); - assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); - // a camera with measured matrices edits in Kelvin (issue #730), under any of its names - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); - let mut p = - lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); - p.kind = MediaKind::Raw; - assert_eq!(Target::for_photo(&p), Target::RawRelative); - p.meta.camera = "SONY ILCE-7M3".into(); - assert_eq!(Target::for_photo(&p), Target::RawAbsolute); - } - - /// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value - /// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on - /// a raw without matrices still takes the #510 shift. - #[test] - fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { - use lightcraft_catalog::MediaKind; - let x = r#""#; - let p = props(x); - let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); - assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); - let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); - let shifted = to_partial(&p, a7m2); - assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); - assert_eq!(shifted["wb"]["tint"], 4.0); - assert!(shifted["wb"].get("scale").is_none()); - // without the reference the Kelvin camera still reads it, the other stays As Shot - let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); - assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); - assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); - // a named preset carries no numbers either way - let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); - assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); - } - - #[test] - fn xmp_preset_with_name_and_group() { - // Written for this test: a minimal preset-shaped packet with localized name/group. - let x = r#" - - Punchy Test - My Looks - "#; - let p = preset_from_xmp(x, "file").unwrap(); - assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd")); - assert_eq!(p.settings, json!({"light": {"contrast": 25.0}})); - assert!(!p.builtin); - assert!(preset_from_xmp("", "f").is_none()); - } - - /// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. - fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { - format!( - r#" - - - - {extra_elems} - "# - ) - } - - #[test] - fn channel_curves_need_the_master_and_all_channel_curves() { - // Lightroom applies red / green / blue curves only when the master and all three are present - let seq = |name: &str, pts: &str| format!("{pts}"); - let identity = "0, 0255, 255"; - let bent = "0, 064, 40255, 255"; - let packet = |curves: &[String]| { - format!( - r#" - {} - "#, - curves.concat() - ) - }; - let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); - let (master, red, green, blue) = ( - seq("ToneCurvePV2012", identity), - seq("ToneCurvePV2012Red", identity), - seq("ToneCurvePV2012Green", bent), - seq("ToneCurvePV2012Blue", identity), - ); - // a channel curve alone, or with the master but without its two siblings: ignored - let alone = partial(std::slice::from_ref(&green)); - assert!(alone.pointer("/curve/green").is_none(), "{alone}"); - let master_and_green = partial(&[master.clone(), green.clone()]); - assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); - // all four: read - let all = partial(&[master, red, green, blue]); - assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); - } - - fn unmapped(x: &str) -> Vec { - to_partial_report(&props(x), None, Target::Any, 1.5).1 - } - - #[test] - fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { - let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; - assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); - // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) - let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; - let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); - assert_eq!(got, ["FutureSlider", "PointColors"]); - } - - #[test] - fn lightroom_profile_bookkeeping_is_not_reported() { - // what Lightroom Classic writes next to every raw's edit: none of it changes the rendering - let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D" - crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False" - crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100" - crs:CropConstrainToUnitSquare="1""#; - let look = r#"Vivid"#; - assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::::new()); - assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap"); - // a lens profile's strength away from 100 % is an adjustment we don't carry over - let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, ""); - assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]); - } - - #[test] - fn disabled_lens_profile_switch_keeps_embedded_corrections() { - // Lightroom applies a file's built-in lens corrections regardless of its profile switch, - // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off - let p = props(&preset_packet("", "")); - let mut d = DevelopSettings::default(); - d.optics.lens_profile = true; - assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); - let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); - assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); - } -} +mod tests; diff --git a/crates/engine/src/crs/tests.rs b/crates/engine/src/crs/tests.rs new file mode 100644 index 000000000..2779194c1 --- /dev/null +++ b/crates/engine/src/crs/tests.rs @@ -0,0 +1,355 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial}; + +/// A hand-written sidecar in attribute form (as many tools write it). +const SIDECAR: &str = r#" + + + + 0, 064, 52192, 205255, 255 + + + 0, 0255, 255 + + + +"#; + +fn props(x: &str) -> Props { + lightcraft_meta::parse_xmp(x).unwrap().properties +} + +#[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_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); + assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0)); + assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0)); + assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0)); + assert_eq!(s.mixer.orange.hue, -7.0); + assert_eq!(s.mixer.blue.sat, -22.0); + assert_eq!(s.mixer.aqua.lum, 13.0); + assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); + assert_eq!(s.curve.master.len(), 4); + 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"); + 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)); + 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)); + assert_eq!(s.geometry.upright, Upright::Level); + assert_eq!(s.geometry.vertical, -10.0); + assert!(s.optics.remove_ca); + assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); + let cal = s.calibration; + assert_eq!( + (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), + (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) + ); + let c = s.crop.geometry; + assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); +} + +/// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve: +/// it is left out, rather than stored with a point the curve editor can't place. +#[test] +fn curves_with_non_finite_points_are_left_out() { + for bad in ["NaN, 0", "64, inf", "-inf, 3"] { + let x = SIDECAR.replace("64, 52", &format!("{bad}")); + let partial = to_partial(&props(&x), Target::RawAbsolute); + let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master); + assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + } +} + +#[test] +fn only_present_fields_are_in_the_partial() { + let x = r#""#; + let partial = to_partial(&props(x), Target::Any); + assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); +} + +#[test] +fn hdr_edit_mode_turns_hdr_editing_on_or_off() { + let x = |v: &str| { + format!( + r#""# + ) + }; + let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); + assert!(on.hdr.enabled); + assert_eq!(on.light.exposure, 1.5); + assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); + let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); + assert!(true_form.hdr.enabled); + let mut hdr = DevelopSettings::default(); + hdr.hdr.enabled = true; + assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); + // without the field nothing about HDR changes + let plain = r#""#; + assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); +} + +#[test] +fn element_form_bw_and_incremental_wb() { + let x = r#" + + True + -35 + 25 + -6 + 4800 + "#; + let p = props(x); + let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); + assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); + assert_eq!(rendered.bw_mix.blue, -35.0); + assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); + assert_eq!(rendered.wb.tint, -6.0); + let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); + assert_eq!(raw.wb.temp, 4800.0); +} + +#[test] +fn named_wb_and_already_applied() { + let x = r#""#; + let p = props(x); + assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); + assert!(!has_adjustments(&p)); + let bookkeeping = r#""#; + assert!(!has_adjustments(&props(bookkeeping))); +} + +#[test] +fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { + // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white + // of 3650 K / −2 (written for this test from the numbers in the issue). + let x = r#""#; + let p = props(x); + let rel = to_partial(&p, Target::RawRelative); + // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale + let t = rel["wb"]["temp"].as_f64().unwrap(); + assert!((t - 6275.0).abs() < 5.0, "{t}"); + assert_eq!(rel["wb"]["tint"], -3.0); + assert_eq!(rel["wb"]["mode"], "custom"); + // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured + // illuminant (and a preset) keeps the Kelvin value + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); + // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); + assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); + assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); + // the as-shot fields are a reference, not an adjustment: nothing to report + assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); + // no tint next to the as-shot tint: the custom tint is taken as the shift itself + let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); + assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); +} + +#[test] +fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { + // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: + // 3578 K read on the relative scale would be a large blue shift (issue #510) + let x = r#""#; + let p = props(x); + let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); + assert_eq!(partial["wb"], json!({"mode": "asShot"})); + assert_eq!(partial["light"]["exposure"], 0.3); + assert_eq!(unmapped.len(), 1, "{unmapped:?}"); + assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); + // a raw with a measured illuminant and a rendered file read it as before, and report nothing + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); + assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); + // an unusable reference (zero) counts as none + let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); + assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); +} + +#[test] +fn target_follows_the_file() { + use lightcraft_catalog::MediaKind; + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); + // Lightroom's `fileFormat` name for proprietary raws + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); + // a raw shown from its embedded preview develops like a rendered file + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); + // a camera with measured matrices edits in Kelvin (issue #730), under any of its names + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); + let mut p = + lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); + p.kind = MediaKind::Raw; + assert_eq!(Target::for_photo(&p), Target::RawRelative); + p.meta.camera = "SONY ILCE-7M3".into(); + assert_eq!(Target::for_photo(&p), Target::RawAbsolute); +} + +/// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value +/// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on +/// a raw without matrices still takes the #510 shift. +#[test] +fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { + use lightcraft_catalog::MediaKind; + let x = r#""#; + let p = props(x); + let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); + assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); + let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); + let shifted = to_partial(&p, a7m2); + assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); + assert_eq!(shifted["wb"]["tint"], 4.0); + assert!(shifted["wb"].get("scale").is_none()); + // without the reference the Kelvin camera still reads it, the other stays As Shot + let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); + assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); + assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); + // a named preset carries no numbers either way + let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); + assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); +} + +#[test] +fn xmp_preset_with_name_and_group() { + // Written for this test: a minimal preset-shaped packet with localized name/group. + let x = r#" + + Punchy Test + My Looks + "#; + let p = preset_from_xmp(x, "file").unwrap(); + assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd")); + assert_eq!(p.settings, json!({"light": {"contrast": 25.0}})); + assert!(!p.builtin); + assert!(preset_from_xmp("", "f").is_none()); +} + +/// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. +fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { + format!( + r#" + + + + {extra_elems} + "# + ) +} + +#[test] +fn channel_curves_need_the_master_and_all_channel_curves() { + // Lightroom applies red / green / blue curves only when the master and all three are present + let seq = |name: &str, pts: &str| format!("{pts}"); + let identity = "0, 0255, 255"; + let bent = "0, 064, 40255, 255"; + let packet = |curves: &[String]| { + format!( + r#" + {} + "#, + curves.concat() + ) + }; + let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); + let (master, red, green, blue) = ( + seq("ToneCurvePV2012", identity), + seq("ToneCurvePV2012Red", identity), + seq("ToneCurvePV2012Green", bent), + seq("ToneCurvePV2012Blue", identity), + ); + // a channel curve alone, or with the master but without its two siblings: ignored + let alone = partial(std::slice::from_ref(&green)); + assert!(alone.pointer("/curve/green").is_none(), "{alone}"); + let master_and_green = partial(&[master.clone(), green.clone()]); + assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); + // all four: read + let all = partial(&[master, red, green, blue]); + assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); +} + +fn unmapped(x: &str) -> Vec { + to_partial_report(&props(x), None, Target::Any, 1.5).1 +} + +#[test] +fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { + let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; + assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); + // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) + let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; + let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); + assert_eq!(got, ["FutureSlider", "PointColors"]); +} + +#[test] +fn lightroom_profile_bookkeeping_is_not_reported() { + // what Lightroom Classic writes next to every raw's edit: none of it changes the rendering + let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D" + crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False" + crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100" + crs:CropConstrainToUnitSquare="1""#; + let look = r#"Vivid"#; + assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::::new()); + assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap"); + // a lens profile's strength away from 100 % is an adjustment we don't carry over + let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, ""); + assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]); +} + +#[test] +fn disabled_lens_profile_switch_keeps_embedded_corrections() { + // Lightroom applies a file's built-in lens corrections regardless of its profile switch, + // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off + let p = props(&preset_packet("", "")); + let mut d = DevelopSettings::default(); + d.optics.lens_profile = true; + assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); + let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); + assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); +} diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 848251c65..a54bd620d 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -446,337 +446,4 @@ pub fn refit_radials(partial: &mut Value, from: f64, to: f64) { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape}; - - /// A packet in the shape local corrections take (values written for this test). - const PACKET: &str = r#" - - - - - - - - - - - - - - - - - - - - - - - d 0.1 0.2d 0.15 0.22 - d 0.5 0.5 - - -"#; - - fn settings() -> (DevelopSettings, Vec) { - let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) - } - - #[test] - fn corrections_become_masks() { - let (s, unmapped) = settings(); - assert_eq!(s.light.exposure, 0.2, "global settings still map"); - assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks); - let sky = &s.masks[0]; - assert_eq!(sky.name, "Darker sky"); - assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0)); - assert_eq!(sky.components[0].shape, MaskShape::Sky); - assert_eq!(sky.components[1].op, MaskOp::Intersect); - let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() }; - assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point"); - let rad = &s.masks[1]; - assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0)); - let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; - assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); - assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); - assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); - assert!(!rad.components[0].invert); - let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; - assert!(lo < hi && *lo_feather > 0.0); - let brush = &s.masks[2]; - assert_eq!(brush.adjust.clarity, 40.0); - assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component"); - let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() }; - assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0)); - assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]); - } - - #[test] - fn masks_render() { - let (s, _) = settings(); - let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; - assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); - } - - #[test] - fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { - // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack - let x = r#" - - - - - - - - -"#; - let d = lightcraft_meta::parse_xmp(x).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).expect("valid settings"); - let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; - assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); - assert_eq!(s.masks[0].adjust.clarity, -30.0); - assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); - } - - #[test] - fn lrtemplate_corrections() { - let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { - { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = { - { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#; - let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap(); - assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped); - let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); - assert_eq!(s.masks.len(), 1); - assert_eq!(s.masks[0].adjust.exposure, -2.0); - } - - #[test] - fn presets_add_masks_once() { - let (s, _) = settings(); - let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}}); - let p = - lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; - let mut mine = DevelopSettings::default(); - mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() }); - let once = p.apply(&mine, 1.0); - assert_eq!(once.masks.len(), 4, "added to the photo's own mask"); - assert_eq!(once.masks[1].id, 8, "fresh ids"); - assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing"); - let half = p.apply(&mine, 0.5); - assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks"); - } - - #[test] - fn sidecar_radials_fit_the_photo() { - let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]}); - refit_radials(&mut v, 1.5, 1.0); - let s = &v["masks"][0]["components"][0]["shape"]; - assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9); - assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); - } - - /// One radial correction in `container`, with `attrs` on its mask component (values written - /// for this test; the component attributes follow what Lightroom Classic writes). - fn radial_packet(container: &str, attrs: &str) -> String { - format!( - r#" - - - - - - - - -"# - ) - } - - fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { - let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - assert!(unmapped.is_empty(), "{unmapped:?}"); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert_eq!(s.masks.len(), 1, "{:?}", s.masks); - s - } - - /// (the radial's `invert`, its feather, the component's `invert`). - fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { - let c = &s.masks[0].components[0]; - let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; - (*invert, *feather, c.invert) - } - - /// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). - fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { - let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; - let render = - |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; - let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); - let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); - (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) - } - - #[test] - fn lightroom_radial_flipped_is_inside() { - // as Lightroom Classic writes an ordinary radial mask today (#1815) - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); - // no MaskInverted at all reads the same - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, false)); - } - - #[test] - fn lightroom_radial_mask_inverted_is_outside() { - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); - // version 2 with Flipped="false": outside, the legacy sense of the flag - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); - assert_eq!(radial_of(&s), (true, 25.0, false)); - } - - #[test] - fn versionless_group_radial_is_inside_with_half_the_feather() { - // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" - for flipped in ["true", "false"] { - let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); - assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); - } - } - - #[test] - fn legacy_radial_filter_is_outside_unless_flipped() { - // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" - let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); - assert_eq!(radial_of(&s), (true, 50.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); - assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); - let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); - assert_eq!(radial_of(&s), (false, 50.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); - } - - #[test] - fn lrtemplate_radial_filter_reads_flipped() { - // .lrtemplate presets and catalog develop settings are the same Lua tables - let t = |flipped: &str| { - format!( - r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ - {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ - {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# - ) - }; - for (flipped, outside) in [("true", false), ("false", true)] { - let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); - let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); - assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); - } - } - - /// Spot removal in the shape Lightroom Classic writes it (values written for this test): - /// a healed circle with an explicit source, a cloned circle with an automatic source, a - /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. - const SPOTS: &str = r#" - - - centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal - - - - - - - - - - - d 0.1 0.8d 0.12 0.82d 0.14 0.84 - - - - - - - - -"#; - - #[test] - fn retouch_areas_become_spots() { - use lightcraft_develop::SpotMode; - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).expect("valid settings"); - assert_eq!(s.light.exposure, 0.1, "global settings still map"); - assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots); - let heal = &s.spots[0]; - assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0)); - assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9); - assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions"); - let src = heal.source_offset.expect("explicit source"); - assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}"); - let clone = &s.spots[1]; - assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None)); - let brushed = &s.spots[2]; - assert_eq!((brushed.points.len(), brushed.size), (3, 0.03)); - let src = brushed.source_offset.expect("explicit source"); - assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9); - let circ = &s.spots[3]; - assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9); - assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}"); - } - - #[test] - fn portrait_target_scales_spot_sizes() { - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size); - } - - #[test] - fn legacy_retouch_info_is_read_on_its_own() { - let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored"); - let d = lightcraft_meta::parse_xmp(&packet).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert_eq!(s.spots.len(), 1, "{:?}", s.spots); - assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal); - assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9); - assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9)); - assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}"); - assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}"); - } - - #[test] - fn spots_render() { - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; - assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); - } -} +mod tests; diff --git a/crates/engine/src/crs_masks/tests.rs b/crates/engine/src/crs_masks/tests.rs new file mode 100644 index 000000000..13386b235 --- /dev/null +++ b/crates/engine/src/crs_masks/tests.rs @@ -0,0 +1,329 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape}; + +/// A packet in the shape local corrections take (values written for this test). +const PACKET: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + d 0.1 0.2d 0.15 0.22 + d 0.5 0.5 + + +"#; + +fn settings() -> (DevelopSettings, Vec) { + let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) +} + +#[test] +fn corrections_become_masks() { + let (s, unmapped) = settings(); + assert_eq!(s.light.exposure, 0.2, "global settings still map"); + assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks); + let sky = &s.masks[0]; + assert_eq!(sky.name, "Darker sky"); + assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0)); + assert_eq!(sky.components[0].shape, MaskShape::Sky); + assert_eq!(sky.components[1].op, MaskOp::Intersect); + let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() }; + assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point"); + let rad = &s.masks[1]; + assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0)); + let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; + assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); + assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); + assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); + assert!(!rad.components[0].invert); + let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; + assert!(lo < hi && *lo_feather > 0.0); + let brush = &s.masks[2]; + assert_eq!(brush.adjust.clarity, 40.0); + assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component"); + let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() }; + assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0)); + assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]); +} + +#[test] +fn masks_render() { + let (s, _) = settings(); + let img = lightcraft_scenes::demo_library()[0].render(96, 64); + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data; + assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); +} + +#[test] +fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#" + + + + + + + + +"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); +} + +#[test] +fn lrtemplate_corrections() { + let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { + { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = { + { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#; + let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap(); + assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(s.masks.len(), 1); + assert_eq!(s.masks[0].adjust.exposure, -2.0); +} + +#[test] +fn presets_add_masks_once() { + let (s, _) = settings(); + let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}}); + let p = lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; + let mut mine = DevelopSettings::default(); + mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() }); + let once = p.apply(&mine, 1.0); + assert_eq!(once.masks.len(), 4, "added to the photo's own mask"); + assert_eq!(once.masks[1].id, 8, "fresh ids"); + assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing"); + let half = p.apply(&mine, 0.5); + assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks"); +} + +#[test] +fn sidecar_radials_fit_the_photo() { + let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]}); + refit_radials(&mut v, 1.5, 1.0); + let s = &v["masks"][0]["components"][0]["shape"]; + assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9); + assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); +} + +/// One radial correction in `container`, with `attrs` on its mask component (values written +/// for this test; the component attributes follow what Lightroom Classic writes). +fn radial_packet(container: &str, attrs: &str) -> String { + format!( + r#" + + + + + + + + +"# + ) +} + +fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { + let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + assert!(unmapped.is_empty(), "{unmapped:?}"); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.masks.len(), 1, "{:?}", s.masks); + s +} + +/// (the radial's `invert`, its feather, the component's `invert`). +fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { + let c = &s.masks[0].components[0]; + let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; + (*invert, *feather, c.invert) +} + +/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). +fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { + let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; + let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); + let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); + (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) +} + +#[test] +fn lightroom_radial_flipped_is_inside() { + // as Lightroom Classic writes an ordinary radial mask today (#1815) + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); + // no MaskInverted at all reads the same + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); +} + +#[test] +fn lightroom_radial_mask_inverted_is_outside() { + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); + // version 2 with Flipped="false": outside, the legacy sense of the flag + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); + assert_eq!(radial_of(&s), (true, 25.0, false)); +} + +#[test] +fn versionless_group_radial_is_inside_with_half_the_feather() { + // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" + for flipped in ["true", "false"] { + let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); + assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); + } +} + +#[test] +fn legacy_radial_filter_is_outside_unless_flipped() { + // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); + assert_eq!(radial_of(&s), (true, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); + assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); + assert_eq!(radial_of(&s), (false, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); +} + +#[test] +fn lrtemplate_radial_filter_reads_flipped() { + // .lrtemplate presets and catalog develop settings are the same Lua tables + let t = |flipped: &str| { + format!( + r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ + {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ + {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# + ) + }; + for (flipped, outside) in [("true", false), ("false", true)] { + let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); + } +} + +/// Spot removal in the shape Lightroom Classic writes it (values written for this test): +/// a healed circle with an explicit source, a cloned circle with an automatic source, a +/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. +const SPOTS: &str = r#" + + + centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal + + + + + + + + + + + d 0.1 0.8d 0.12 0.82d 0.14 0.84 + + + + + + + + +"#; + +#[test] +fn retouch_areas_become_spots() { + use lightcraft_develop::SpotMode; + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + assert_eq!(s.light.exposure, 0.1, "global settings still map"); + assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots); + let heal = &s.spots[0]; + assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0)); + assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9); + assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions"); + let src = heal.source_offset.expect("explicit source"); + assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}"); + let clone = &s.spots[1]; + assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None)); + let brushed = &s.spots[2]; + assert_eq!((brushed.points.len(), brushed.size), (3, 0.03)); + let src = brushed.source_offset.expect("explicit source"); + assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9); + let circ = &s.spots[3]; + assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9); + assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}"); +} + +#[test] +fn portrait_target_scales_spot_sizes() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size); +} + +#[test] +fn legacy_retouch_info_is_read_on_its_own() { + let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored"); + let d = lightcraft_meta::parse_xmp(&packet).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.spots.len(), 1, "{:?}", s.spots); + assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal); + assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9); + assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9)); + assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}"); + assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}"); +} + +#[test] +fn spots_render() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(96, 64); + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data; + assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 38a42fe95..c89f29e0a 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -329,366 +329,4 @@ fn trim_scene_cache(c: &mut SceneCache, limit: usize) { } #[cfg(test)] -mod tests { - use super::*; - - fn small_scene() -> lightcraft_scenes::Scene { - lightcraft_scenes::demo_library().swap_remove(0) - } - - #[test] - fn a_scene_is_generated_once_per_size_and_shared() { - let s = small_scene(); - let a = scene_pixels(&s, 48).unwrap(); - let b = scene_pixels(&s, 48).unwrap(); - assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation"); - assert_eq!(a.width.max(a.height), 48); - assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly"); - let c = scene_pixels(&s, 40).unwrap(); - assert_eq!(c.width.max(c.height), 40, "another size is its own generation"); - } - - #[test] - fn concurrent_requests_wait_for_one_generation() { - let s = small_scene(); - let edge = 37; // a size no other test asks for - let got: Vec<_> = std::thread::scope(|sc| { - let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect(); - hs.into_iter().map(|h| h.join().unwrap()).collect() - }); - assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); - } - - /// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it. - fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(f()); - }); - rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked") - } - - /// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation. - fn until_waiting(slot: &SceneSlot, n: usize) { - let started = std::time::Instant::now(); - while slot.lock().waiting < n { - assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait"); - std::thread::sleep(std::time::Duration::from_millis(1)); - } - } - - fn pixels(n: usize) -> lightcraft_raster::Rgb32f { - lightcraft_raster::Rgb32f::new(n, 1) - } - - /// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker - /// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the - /// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests - /// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one - /// of them must come back, at least four of them must have overlapped the generation, and the test waits with a - /// timeout, so a deadlock fails it instead of hanging. - #[test] - fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() { - let scene = Arc::new(small_scene()); - let edge = 601; // a size no other test asks for - const ASKED: usize = 24; - let (overlap, sizes) = within(120, move || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); - let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - for _ in 0..ASKED { - let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone()); - issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - pool.spawn(move || { - let got = scene_pixels(&scene, edge).unwrap(); - // how many requests had been made when this one came back - let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len())); - }); - std::thread::sleep(std::time::Duration::from_millis(1)); - } - let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect(); - (replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::>()) - }); - assert_eq!(sizes.len(), ASKED, "every request came back"); - assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0)); - assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)"); - } - - /// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered - /// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original). - #[test] - fn a_scene_is_rendered_once_however_many_ask() { - use std::sync::atomic::{AtomicUsize, Ordering}; - let (renders, peak) = within(120, || { - let slot = Arc::new(SceneSlot::default()); - let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); - let render = { - let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone()); - move |how: Render| { - let now = running.fetch_add(1, Ordering::SeqCst) + 1; - peak.fetch_max(now, Ordering::SeqCst); - renders.fetch_add(1, Ordering::SeqCst); - // work like the real render's: in parallel, the rendering thread waits for rows other threads took - use rayon::prelude::*; - let sum: u64 = match how { - Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(), - Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(), - }; - std::thread::sleep(std::time::Duration::from_millis(100)); - running.fetch_sub(1, Ordering::SeqCst); - pixels(sum as usize % 5 + 1) - } - }; - let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - for _ in 0..16 { - let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); - pool.spawn(move || { - let _ = tx.send(slot.get_or_generate(render).unwrap()); - }); - } - for _ in 0..4 { - let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); - std::thread::spawn(move || { - let _ = tx.send(slot.get_or_generate(render).unwrap()); - }); - } - let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect(); - assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); - (renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst)) - }); - assert_eq!((renders, peak), (1, 1)); - } - - /// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool - /// and that pool's limits; other threads render in parallel on the scene pool. - #[test] - fn a_rayon_worker_renders_alone_on_its_own_thread() { - let ((how, caller, renderer), (other_how, other_name)) = within(120, || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); - let slot = SceneSlot::default(); - let mut seen = None; - pool.install(|| { - let caller = std::thread::current().id(); - slot.get_or_generate(|how| { - seen = Some((how, caller, std::thread::current().id())); - pixels(1) - }) - .unwrap(); - }); - let other = SceneSlot::default(); - let mut other_seen = None; - other - .get_or_generate(|how| { - other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string())); - pixels(1) - }) - .unwrap(); - (seen.unwrap(), other_seen.unwrap()) - }); - assert_eq!((how, renderer), (Render::Serial, caller)); - assert_eq!(other_how, Render::Parallel); - assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}"); - } - - /// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs - /// minutes of CPU time on a small machine). - #[test] - fn callers_wait_for_the_generation_running() { - let (a, b, renders) = within(120, || { - let slot = Arc::new(SceneSlot::default()); - let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (started_tx, started) = std::sync::mpsc::channel::<()>(); - let (go, go_rx) = std::sync::mpsc::channel::<()>(); - let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx))); - let ask = |slot: &Arc| { - let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone()); - std::thread::spawn(move || { - slot.get_or_generate(move |_| { - renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _ = started_tx.lock().unwrap().send(()); - let _ = go_rx.lock().unwrap().recv(); - pixels(1) - }) - }) - }; - let first = ask(&slot); - started.recv().unwrap(); - let second = ask(&slot); - until_waiting(&slot, 1); - go.send(()).unwrap(); - let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap()); - (a, b, renders.load(std::sync::atomic::Ordering::SeqCst)) - }); - assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared"); - assert_eq!(renders, 1); - } - - /// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request - /// generates the scene again. - #[test] - fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { - let (first, waiters, again) = within(120, || { - let slot = Arc::new(SceneSlot::default()); - let (started_tx, started) = std::sync::mpsc::channel::<()>(); - let (go, go_rx) = std::sync::mpsc::channel::<()>(); - let failing = { - let slot = slot.clone(); - std::thread::spawn(move || { - slot.get_or_generate(move |_| { - let _ = started_tx.send(()); - let _ = go_rx.recv(); - panic!("render failed"); - }) - }) - }; - started.recv().unwrap(); - let waiters: Vec<_> = (0..2) - .map(|_| { - let slot = slot.clone(); - std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) - }) - .collect(); - until_waiting(&slot, 2); - drop(go); - let first = failing.join().unwrap(); - let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect(); - (first, waiters, slot.get_or_generate(|_| pixels(3))) - }); - assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error"); - assert_eq!(again.unwrap().width, 3); - } - - /// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they - /// look: a failed generation followed by a retry is still an error for them. - #[test] - fn a_retry_never_changes_the_outcome_waited_for() { - let mut st = SlotState::default(); - let n = st.start(); - assert!(st.outcome(n).is_none(), "running"); - st.generation = Generation::Failed; - let m = st.start(); - assert!(matches!(st.outcome(n), Some(Err(_)))); - assert!(st.outcome(m).is_none()); - st.generation = Generation::Ready(Arc::new(pixels(1))); - assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); - assert!(matches!(st.outcome(m), Some(Ok(_)))); - // and with threads: a retry racing the waiters of a failed generation never hands them its result - let wrong = within(120, || { - let mut wrong = 0; - for _ in 0..20 { - let slot = Arc::new(SceneSlot::default()); - let (started_tx, started) = std::sync::mpsc::channel::<()>(); - let (go, go_rx) = std::sync::mpsc::channel::<()>(); - let failing = { - let slot = slot.clone(); - std::thread::spawn(move || { - slot.get_or_generate(move |_| { - let _ = started_tx.send(()); - let _ = go_rx.recv(); - panic!("render failed"); - }) - }) - }; - started.recv().unwrap(); - let waiters: Vec<_> = (0..3) - .map(|_| { - let slot = slot.clone(); - std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) - }) - .collect(); - until_waiting(&slot, 3); - let retry = { - let slot = slot.clone(); - std::thread::spawn(move || { - loop { - if matches!(slot.lock().generation, Generation::Failed) { - return slot.get_or_generate(|_| pixels(4)); - } - std::hint::spin_loop(); - } - }) - }; - drop(go); - let _ = failing.join().unwrap(); - wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count(); - assert!(retry.join().unwrap().is_ok()); - } - wrong - }); - assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels"); - } - - /// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it - /// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's. - #[test] - fn scenes_are_generated_when_the_global_pool_is_broken() { - use std::io::Read; - use std::process::Stdio; - let mut child = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"]) - .env("LC_DEMO_BROKEN_GLOBAL_POOL", "1") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - // Drain both pipes on threads so a chatty child can't block on a full pipe. - let drain = |mut r: Box| { - std::thread::spawn(move || { - let mut s = String::new(); - let _ = r.read_to_string(&mut s); - s - }) - }; - let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap()))); - // A deadlocked child fails the test instead of hanging CI. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); - let status = loop { - if let Some(status) = child.try_wait().unwrap() { - break status; - } - if std::time::Instant::now() > deadline { - let _ = child.kill(); - let _ = child.wait(); - panic!("the child process did not finish within 120 s"); - } - std::thread::sleep(std::time::Duration::from_millis(20)); - }; - let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap()); - assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}"); - } - - #[test] - #[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"] - fn with_a_broken_global_pool() { - if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() { - return; - } - let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global(); - assert!(broken.is_err()); - assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken"); - let s = small_scene(); - let got = scene_pixels(&s, 33).unwrap(); - assert_eq!(got.data, s.render_fit_serial(33).data); - } - - #[test] - fn the_cache_keeps_the_most_recent_scenes_within_its_limit() { - let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge }; - let entry = |max_edge, used, px: Option| { - let slot = Arc::new(SceneSlot::default()); - if let Some(n) = px { - slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); - } - SceneEntry { key: key(max_edge), slot, used } - }; - let px = std::mem::size_of::<[f32; 3]>(); - let mut c = SceneCache { entries: vec![entry(1, 3, Some(10)), entry(2, 1, Some(10)), entry(3, 0, None), entry(4, 2, Some(10))], tick: 3 }; - trim_scene_cache(&mut c, 25 * px); - let mut kept: Vec = c.entries.iter().map(|e| e.key.max_edge).collect(); - kept.sort_unstable(); - assert_eq!(kept, vec![1, 3, 4], "the least recently used finished scene went; the one in progress stays"); - trim_scene_cache(&mut c, 0); - assert_eq!(c.entries.iter().map(|e| e.key.max_edge).collect::>(), vec![3], "only the scene in progress is left"); - } -} +mod tests; diff --git a/crates/engine/src/demo/tests.rs b/crates/engine/src/demo/tests.rs new file mode 100644 index 000000000..21823fa9a --- /dev/null +++ b/crates/engine/src/demo/tests.rs @@ -0,0 +1,361 @@ +use super::*; + +fn small_scene() -> lightcraft_scenes::Scene { + lightcraft_scenes::demo_library().swap_remove(0) +} + +#[test] +fn a_scene_is_generated_once_per_size_and_shared() { + let s = small_scene(); + let a = scene_pixels(&s, 48).unwrap(); + let b = scene_pixels(&s, 48).unwrap(); + assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation"); + assert_eq!(a.width.max(a.height), 48); + assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly"); + let c = scene_pixels(&s, 40).unwrap(); + assert_eq!(c.width.max(c.height), 40, "another size is its own generation"); +} + +#[test] +fn concurrent_requests_wait_for_one_generation() { + let s = small_scene(); + let edge = 37; // a size no other test asks for + let got: Vec<_> = std::thread::scope(|sc| { + let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); +} + +/// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it. +fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked") +} + +/// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation. +fn until_waiting(slot: &SceneSlot, n: usize) { + let started = std::time::Instant::now(); + while slot.lock().waiting < n { + assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + +fn pixels(n: usize) -> lightcraft_raster::Rgb32f { + lightcraft_raster::Rgb32f::new(n, 1) +} + +/// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker +/// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the +/// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests +/// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one +/// of them must come back, at least four of them must have overlapped the generation, and the test waits with a +/// timeout, so a deadlock fails it instead of hanging. +#[test] +fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() { + let scene = Arc::new(small_scene()); + let edge = 601; // a size no other test asks for + const ASKED: usize = 24; + let (overlap, sizes) = within(120, move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + for _ in 0..ASKED { + let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone()); + issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + pool.spawn(move || { + let got = scene_pixels(&scene, edge).unwrap(); + // how many requests had been made when this one came back + let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len())); + }); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect(); + (replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::>()) + }); + assert_eq!(sizes.len(), ASKED, "every request came back"); + assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0)); + assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)"); +} + +/// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered +/// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original). +#[test] +fn a_scene_is_rendered_once_however_many_ask() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (renders, peak) = within(120, || { + let slot = Arc::new(SceneSlot::default()); + let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); + let render = { + let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone()); + move |how: Render| { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + renders.fetch_add(1, Ordering::SeqCst); + // work like the real render's: in parallel, the rendering thread waits for rows other threads took + use rayon::prelude::*; + let sum: u64 = match how { + Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(), + Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(), + }; + std::thread::sleep(std::time::Duration::from_millis(100)); + running.fetch_sub(1, Ordering::SeqCst); + pixels(sum as usize % 5 + 1) + } + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + for _ in 0..16 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + pool.spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + for _ in 0..4 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + std::thread::spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect(); + assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); + (renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst)) + }); + assert_eq!((renders, peak), (1, 1)); +} + +/// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool +/// and that pool's limits; other threads render in parallel on the scene pool. +#[test] +fn a_rayon_worker_renders_alone_on_its_own_thread() { + let ((how, caller, renderer), (other_how, other_name)) = within(120, || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let slot = SceneSlot::default(); + let mut seen = None; + pool.install(|| { + let caller = std::thread::current().id(); + slot.get_or_generate(|how| { + seen = Some((how, caller, std::thread::current().id())); + pixels(1) + }) + .unwrap(); + }); + let other = SceneSlot::default(); + let mut other_seen = None; + other + .get_or_generate(|how| { + other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string())); + pixels(1) + }) + .unwrap(); + (seen.unwrap(), other_seen.unwrap()) + }); + assert_eq!((how, renderer), (Render::Serial, caller)); + assert_eq!(other_how, Render::Parallel); + assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}"); +} + +/// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs +/// minutes of CPU time on a small machine). +#[test] +fn callers_wait_for_the_generation_running() { + let (a, b, renders) = within(120, || { + let slot = Arc::new(SceneSlot::default()); + let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx))); + let ask = |slot: &Arc| { + let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone()); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _ = started_tx.lock().unwrap().send(()); + let _ = go_rx.lock().unwrap().recv(); + pixels(1) + }) + }) + }; + let first = ask(&slot); + started.recv().unwrap(); + let second = ask(&slot); + until_waiting(&slot, 1); + go.send(()).unwrap(); + let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap()); + (a, b, renders.load(std::sync::atomic::Ordering::SeqCst)) + }); + assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared"); + assert_eq!(renders, 1); +} + +/// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request +/// generates the scene again. +#[test] +fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { + let (first, waiters, again) = within(120, || { + let slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..2) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 2); + drop(go); + let first = failing.join().unwrap(); + let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect(); + (first, waiters, slot.get_or_generate(|_| pixels(3))) + }); + assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error"); + assert_eq!(again.unwrap().width, 3); +} + +/// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they +/// look: a failed generation followed by a retry is still an error for them. +#[test] +fn a_retry_never_changes_the_outcome_waited_for() { + let mut st = SlotState::default(); + let n = st.start(); + assert!(st.outcome(n).is_none(), "running"); + st.generation = Generation::Failed; + let m = st.start(); + assert!(matches!(st.outcome(n), Some(Err(_)))); + assert!(st.outcome(m).is_none()); + st.generation = Generation::Ready(Arc::new(pixels(1))); + assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); + assert!(matches!(st.outcome(m), Some(Ok(_)))); + // and with threads: a retry racing the waiters of a failed generation never hands them its result + let wrong = within(120, || { + let mut wrong = 0; + for _ in 0..20 { + let slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..3) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 3); + let retry = { + let slot = slot.clone(); + std::thread::spawn(move || { + loop { + if matches!(slot.lock().generation, Generation::Failed) { + return slot.get_or_generate(|_| pixels(4)); + } + std::hint::spin_loop(); + } + }) + }; + drop(go); + let _ = failing.join().unwrap(); + wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count(); + assert!(retry.join().unwrap().is_ok()); + } + wrong + }); + assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels"); +} + +/// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it +/// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's. +#[test] +fn scenes_are_generated_when_the_global_pool_is_broken() { + use std::io::Read; + use std::process::Stdio; + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"]) + .env("LC_DEMO_BROKEN_GLOBAL_POOL", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Drain both pipes on threads so a chatty child can't block on a full pipe. + let drain = |mut r: Box| { + std::thread::spawn(move || { + let mut s = String::new(); + let _ = r.read_to_string(&mut s); + s + }) + }; + let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap()))); + // A deadlocked child fails the test instead of hanging CI. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("the child process did not finish within 120 s"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap()); + assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}"); +} + +#[test] +#[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"] +fn with_a_broken_global_pool() { + if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() { + return; + } + let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global(); + assert!(broken.is_err()); + assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken"); + let s = small_scene(); + let got = scene_pixels(&s, 33).unwrap(); + assert_eq!(got.data, s.render_fit_serial(33).data); +} + +#[test] +fn the_cache_keeps_the_most_recent_scenes_within_its_limit() { + let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge }; + let entry = |max_edge, used, px: Option| { + let slot = Arc::new(SceneSlot::default()); + if let Some(n) = px { + slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); + } + SceneEntry { key: key(max_edge), slot, used } + }; + let px = std::mem::size_of::<[f32; 3]>(); + let mut c = SceneCache { entries: vec![entry(1, 3, Some(10)), entry(2, 1, Some(10)), entry(3, 0, None), entry(4, 2, Some(10))], tick: 3 }; + trim_scene_cache(&mut c, 25 * px); + let mut kept: Vec = c.entries.iter().map(|e| e.key.max_edge).collect(); + kept.sort_unstable(); + assert_eq!(kept, vec![1, 3, 4], "the least recently used finished scene went; the one in progress stays"); + trim_scene_cache(&mut c, 0); + assert_eq!(c.entries.iter().map(|e| e.key.max_edge).collect::>(), vec![3], "only the scene in progress is left"); +} diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index 8793b3a5b..44ef3eef7 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -36,1918 +36,32 @@ use crate::merge::ByteReader; use crate::model_download::Downloads; use crate::{Selection, Session}; -/// Extension of a product file. -pub const EXT: &str = "lcdn"; -/// Where the products of a library are kept, inside the library's folder (safe to delete, like `thumbs/`). -pub const DIR: &str = "denoise"; -/// What the cache may take up unless the user says otherwise (GB). A product is about as large as the raw file's -/// decoded samples, compressed: ~100 MB for 24 megapixels. -pub const DEFAULT_CACHE_GB: u32 = 20; -/// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them. -const CLIP: f32 = 0.99; -/// Most photos waiting in the queue. -const MAX_QUEUE: usize = 5000; -/// Most selected photos the automatic queue looks at in one pump. -const AUTO_LOOK: usize = 64; -/// How long a look at the model settings is trusted. -const SETTINGS_TTL: Duration = Duration::from_secs(2); -/// Stack of the threads that run the model (the runtime recurses through its graph). -const STACK: usize = 16 << 20; +mod gpu; +mod model; +mod products; +mod session; +mod settings; +mod state; -// --------------------------------------------------------------------------------------------------------------------- -// the model - -/// Where the model runs (Settings ▸ AI Denoise ▸ Speed; `denoise.settings {runOn}`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum RunOn { - /// The graphics card where it is faster than the processor at the pace the work goes at (both are timed on this - /// computer when the card is set up), else the processor. - #[default] - Auto, - /// The graphics card whenever it can run the model, even where the processor would be faster. - Gpu, - /// Always the processor. - Cpu, -} - -impl RunOn { - /// `auto`, `gpu` or `cpu`. - pub fn parse(name: &str) -> Option { - match name { - "auto" => Some(RunOn::Auto), - "gpu" => Some(RunOn::Gpu), - "cpu" => Some(RunOn::Cpu), - _ => None, - } - } - - pub fn name(self) -> &'static str { - match self { - RunOn::Auto => "auto", - RunOn::Gpu => "gpu", - RunOn::Cpu => "cpu", - } - } -} - -/// A loaded denoise model: what runs tiles, and a self-check. -pub(crate) trait Model: Send + Sync { - /// What runs a photo's tiles, and how many to run at once, when `threads` may be used and the setting is `run_on` - /// (a graphics card is kept busy by a few). The first time the card may be used it is set up and checked here. - fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize); - /// Check it does something sensible: the test's result, or why not. Unless `run_on` is the processor the graphics - /// card is set up and checked too, and the result says where the work will run. - fn self_test(&self, run_on: RunOn) -> Result; - /// Conservative scratch bound for an unknown runner. - fn bytes_per_tile(&self) -> usize { - 512 * 1024 * 1024 - } - /// Where the tiles run: `{kind: "gpu", adapter}`, `{kind: "cpu", reason?}` or `{kind: "pending"}` (the card is not - /// set up yet), with the card's and the processor's time for the check tile (`cardMs`, `cpuMs`) once both were - /// timed. Never starts anything. - fn device(&self, _run_on: RunOn) -> Value { - json!({"kind": "cpu"}) - } -} - -/// Loads the model at a path as its manifest describes it. -pub(crate) type Loader = Arc Result, String> + Send + Sync>; - -/// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended. +#[cfg(test)] +pub(crate) use gpu::GPU_SETUP_MARKER; +#[cfg(all(feature = "denoise", test))] +use gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, SETTING_UP, card_is_faster, set_up_card}; +pub use model::RunOn; +#[cfg(all(feature = "denoise", test))] +pub(crate) use model::default_loader; +#[cfg(all(feature = "denoise", test))] +use model::{GpuSide, Network, RemoteSide, network, worth_retrying}; +pub(crate) use model::{Loader, Model}; #[cfg(feature = "denoise")] -const GPU_PARALLEL: usize = 4; -/// How far the GPU's answer on the check tile may be from the CPU's (of the answer's largest value) before it is not used. -#[cfg(feature = "denoise")] -const GPU_AGREE: f32 = 1e-3; -/// Longest the card may take to set up (build its kernels, run the check tile, time it) before the processor does the -/// work instead. -#[cfg(feature = "denoise")] -const GPU_SETUP_LIMIT: Duration = Duration::from_secs(30); -/// Tiles the card may get wrong (an error, numbers that are not finite) before it stops being used for the model. -#[cfg(feature = "denoise")] -const GPU_MAX_FALLBACKS: usize = 3; -/// The file beside a model while the graphics card is set up for it (see [`set_up_card`]). -pub(crate) const GPU_SETUP_MARKER: &str = "gpu-setup.marker"; -/// Why the card is not tried when a set-up marker is found. -#[cfg(feature = "denoise")] -const GPU_CRASHED: &str = "LightCraft closed while it was setting up the graphics card for AI Denoise last time"; -/// Why the card is not used when its set-up ran out of time (the start of it). -#[cfg(feature = "denoise")] -const GPU_SLOW: &str = "setting up the graphics card took longer than"; -/// Why the card is not used while a set-up that ran out of time is still going. -#[cfg(feature = "denoise")] -const GPU_STUCK: &str = "the graphics card is still setting up from before (it took too long)"; - -/// A set-up that may work another time (a crash, the time limit), unlike one the card or the model cannot do. -#[cfg(feature = "denoise")] -fn worth_retrying(why: &str) -> bool { - why == GPU_CRASHED || why == GPU_STUCK || why.starts_with(GPU_SLOW) -} - -/// The model on our pure-Rust CPU runner, and on the graphics card when there is one that can run it: the card is set up for the -/// first photo that may use it, checked against the CPU's answer on a check tile and timed against it, and used while -/// it keeps working; a tile it gets wrong is run on the CPU, so a photo is always finished. -#[cfg(feature = "denoise")] -struct Network { - cpu: lightcraft_denoise::runtime::CpuRunner, - path: PathBuf, - tile: usize, - gpu: OnceLock>, - /// Tiles the card got wrong (an error, numbers that are not finite) and the CPU ran. - fallbacks: AtomicUsize, - /// Tiles at once of the photo started last (0: none yet), to say where the work runs. - last_threads: AtomicUsize, - /// Another computer's graphics card (`LIGHTCRAFT_DENOISE_URL` + `LIGHTCRAFT_DENOISE_TOKEN`; see - /// `docs/denoise.md` → Another computer's graphics card), greeted on first use: it takes tiles while it has a free - /// slot, on top of this computer's runner. - remote: Option, - /// Whether this computer's share runs on the card (else the CPU), as chosen for the photo started last. - local_card: AtomicBool, -} - -#[cfg(feature = "denoise")] -struct RemoteSide { - runner: lightcraft_denoise::remote::Remote, - /// The greeting, and the server's time for the check tile there and back (ms; the faster of two runs). - greeted: OnceLock>, - /// Whether the photo started last shares tiles with the server, and if not because it is slower, the two times. - share: AtomicBool, - slower: Mutex>, -} - -#[cfg(feature = "denoise")] -impl RemoteSide { - /// From the environment, for the model whose file has `sha256` and tiles of `tile` cells. - fn from_env(sha256: &str, tile: usize) -> Option { - let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); - let address = var("LIGHTCRAFT_DENOISE_URL")?; - let token = var("LIGHTCRAFT_DENOISE_TOKEN").unwrap_or_default(); - Some(RemoteSide::new(lightcraft_denoise::remote::Remote::new(&address, &token, sha256, tile))) - } - - fn new(runner: lightcraft_denoise::remote::Remote) -> RemoteSide { - RemoteSide { runner, greeted: OnceLock::new(), share: AtomicBool::new(false), slower: Mutex::new(None) } - } - - /// Greet the server and time a check tile there and back: a server behind a slow link can take seconds a tile, and - /// handing it tiles a fast local card would finish sooner only makes the photo wait for it. - fn greet(&self) -> Result<(lightcraft_denoise::remote::ServerInfo, f64), String> { - let info = self.runner.hello().map_err(|e| e.to_string())?; - let input = lightcraft_denoise::runtime::check_tile(info.tile); - let mut best = f64::INFINITY; - for _ in 0..2 { - let started = web_time::Instant::now(); - match self.runner.try_run(&input) { - Some(Ok(_)) => best = best.min(started.elapsed().as_secs_f64() * 1000.0), - Some(Err(e)) => return Err(format!("the check tile failed: {e}")), - None => return Err("no free slot for the check tile".into()), - } - } - Ok((info, best)) - } - - /// The server when it can take tiles now (greeting it the first time), else why not. - fn usable(&self, start: bool) -> Result<&lightcraft_denoise::remote::Remote, String> { - let greeted = if start { Some(self.greeted.get_or_init(|| self.greet())) } else { self.greeted.get() }; - match greeted { - None => Err("not contacted yet".into()), - Some(Err(why)) => Err(why.clone()), - Some(Ok(_)) if !self.runner.healthy() => { - Err(format!("it failed {} tiles ({})", self.runner.failures(), self.runner.last_error().unwrap_or_default())) - } - Some(Ok(_)) => Ok(&self.runner), - } - } - - /// The server's time for a tile, once greeted. - fn tile_ms(&self) -> Option { - self.greeted.get().and_then(|g| g.as_ref().ok()).map(|g| g.1) - } - - fn describe(&self) -> Value { - match self.greeted.get() { - None => json!({"address": self.runner.address(), "state": "pending"}), - Some(Err(why)) => json!({"address": self.runner.address(), "state": "unavailable", "reason": why}), - Some(Ok((info, ms))) => { - let slower = *self.slower.lock().unwrap_or_else(PoisonError::into_inner); - let state = match () { - _ if !self.runner.healthy() => "given up", - _ if slower.is_some() => "slower", - _ if self.share.load(Ordering::Relaxed) => "used", - _ => "idle", - }; - let mut v = - json!({"address": info.address, "device": info.device, "state": state, "remoteMs": ms, "failures": self.runner.failures()}); - if let (Some((_, local)), Some(o)) = (slower, v.as_object_mut()) { - o.insert("localMs".into(), json!(local)); - } - if let (Some(e), Some(o)) = (self.runner.last_error(), v.as_object_mut()) { - o.insert("lastError".into(), json!(e)); - } - v - } - } - } -} - -#[cfg(feature = "denoise")] -struct GpuSide { - runner: Box, - adapter: String, - /// The check tile on the card, the fastest of a few runs (ms). - card_ms: f64, - /// The check tile on the CPU, on one thread (ms). - cpu_ms: f64, -} - -/// Whether the card (`card_ms` a tile) is faster than the CPU running `threads` tiles at once, from the CPU's time for -/// one tile alone (`cpu_ms`). Tiles at once share the memory and the cores' second threads, so each one more counts as -/// half a tile's speed: on a 16-core laptop one tile alone took 1.4 s and sixteen at once finished one every 0.2 s. -#[cfg(feature = "denoise")] -fn card_is_faster(card_ms: f64, cpu_ms: f64, threads: usize) -> bool { - let at_once = 1.0 + 0.5 * threads.saturating_sub(1) as f64; - card_ms.is_finite() && card_ms <= cpu_ms / at_once -} - -/// Card set-ups of this process that have not finished, by marker (their markers are not a crash's). -#[cfg(feature = "denoise")] -static SETTING_UP: Mutex> = Mutex::new(Vec::new()); - -/// Removes a set-up's marker when the set-up ends, however it ends — but not when the process dies inside the driver. -#[cfg(feature = "denoise")] -struct SetUpEnded(PathBuf); - -#[cfg(feature = "denoise")] -impl Drop for SetUpEnded { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner).retain(|m| m != &self.0); - } -} - -/// Set the graphics card up with `card` on a thread of its own while `meanwhile` runs here, and give up on the card when -/// it takes longer than `limit` (a driver building kernels for minutes, or stuck). The `marker` file exists while `card` -/// runs: one found before it starts was left by a LightCraft that closed during a set-up (a driver crash takes the app -/// with it), so the card is not tried again until the user chooses where denoise runs (`denoise.settings {runOn}`). -#[cfg(feature = "denoise")] -fn set_up_card( - marker: &Path, - limit: Duration, - card: impl FnOnce() -> Result + Send + 'static, - meanwhile: impl FnOnce() -> R, -) -> Result<(T, R), String> { - let ended = { - let mut running = SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner); - if running.iter().any(|m| m == marker) { - return Err(GPU_STUCK.into()); - } - if marker.exists() { - return Err(GPU_CRASHED.into()); - } - let secs = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_secs()); - // a marker that cannot be written only loses the crash guard - let _ = std::fs::write(marker, format!("setting up the graphics card for AI Denoise (unix time {secs})\n")); - running.push(marker.to_path_buf()); - SetUpEnded(marker.to_path_buf()) - }; - let started = web_time::Instant::now(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::Builder::new() - .name("denoise-gpu-setup".into()) - .stack_size(STACK) - .spawn(move || { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(card)).unwrap_or_else(|_| Err("the GPU runner gave up".into())); - drop(ended); - let _ = tx.send(r); - }) - .map_err(|e| format!("could not start setting up the graphics card: {e}"))?; - let here = meanwhile(); - match rx.recv_timeout(limit.saturating_sub(started.elapsed())) { - Ok(r) => r.map(|t| (t, here)), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(format!("{GPU_SLOW} {} s, so it is not used", limit.as_secs_f64())), - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err("the GPU runner gave up".into()), - } -} - -/// The card's time for one tile once it is warm: the fastest of up to three runs, stopping after about two seconds (a -/// slow card is slow enough to tell at once). -#[cfg(feature = "denoise")] -fn card_tile_ms(runner: &dyn TileRunner, input: &[f32]) -> f64 { - if runner.run(input).is_err() { - return f64::INFINITY; - } - let began = web_time::Instant::now(); - let mut best = f64::INFINITY; - for _ in 0..3 { - let one = web_time::Instant::now(); - if runner.run(input).is_err() { - break; - } - best = best.min(one.elapsed().as_secs_f64() * 1000.0); - if began.elapsed() > Duration::from_secs(2) { - break; - } - } - best -} - -/// Set the card up for the model at `path`: kernels built, the check tile run on both and compared, both timed. -#[cfg(feature = "denoise")] -fn make_gpu(cpu: &lightcraft_denoise::runtime::CpuRunner, path: &Path, tile: usize) -> Result { - let input = Arc::new(lightcraft_denoise::runtime::check_tile(tile)); - let (on_card, net) = (input.clone(), cpu.net().clone()); - let card = move || -> Result<_, String> { - let runner = lightcraft_gpu::nn::runner(&net, tile)?; - let got = runner.run(&on_card).map_err(|e| format!("the GPU could not run the check tile: {e}"))?; - let card_ms = card_tile_ms(&runner, &on_card); - Ok((runner, got, card_ms)) - }; - let on_cpu = || { - let started = web_time::Instant::now(); - (cpu.run(&input), started.elapsed().as_secs_f64() * 1000.0) - }; - let ((runner, got, card_ms), (want, cpu_ms)) = set_up_card(&path.with_file_name(GPU_SETUP_MARKER), GPU_SETUP_LIMIT, card, on_cpu)?; - let want = want.map_err(|e| format!("the CPU could not check the GPU: {e}"))?; - let scale = want.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-12); - let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale; - if got.len() != want.len() || !got.iter().all(|v| v.is_finite()) || worst > GPU_AGREE { - return Err(format!("its answer on the check tile is not the CPU's (off by {worst:.1e} of the largest value): not used")); - } - Ok(GpuSide { adapter: runner.adapter(), runner: Box::new(runner), card_ms, cpu_ms }) -} - -#[cfg(feature = "denoise")] -impl Network { - /// The card when it can be used now, else why not. `start` sets it up when that has not been tried yet; without it - /// nothing is started. - fn card(&self, start: bool) -> Result<&GpuSide, String> { - let side = if start { - Some(self.gpu.get_or_init(|| { - let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_gpu(&self.cpu, &self.path, self.tile))) - .unwrap_or_else(|_| Err("the GPU runner gave up".into())); - if let Err(why) = &made { - log::info!("denoise: running on the CPU ({why})"); - } - made - })) - } else { - self.gpu.get() - }; - let g = match side { - None => return Err("the graphics card is not set up yet".into()), - Some(Err(why)) => return Err(why.clone()), - Some(Ok(g)) => g, - }; - if let Some(why) = lightcraft_gpu::nn::broken_reason() { - return Err(format!("the graphics card stopped working ({why})")); - } - if self.fallbacks.load(Ordering::Relaxed) >= GPU_MAX_FALLBACKS { - return Err(format!("{} got {GPU_MAX_FALLBACKS} tiles wrong", g.adapter)); - } - Ok(g) - } - - /// The card, when a photo made `threads` tiles at a time under `run_on` runs on it; else why the CPU does. - fn choose(&self, run_on: RunOn, threads: usize, start: bool) -> Result<&GpuSide, String> { - if run_on == RunOn::Cpu { - return Err("the processor is chosen in Settings".into()); - } - let g = self.card(start)?; - if run_on == RunOn::Auto && !card_is_faster(g.card_ms, g.cpu_ms, threads) { - return Err(format!("it is faster than {} on this computer", g.adapter)); - } - Ok(g) - } - - fn fell_back(&self, why: &str) { - let n = self.fallbacks.fetch_add(1, Ordering::Relaxed) + 1; - if n == 1 { - log::warn!("denoise: a tile came back wrong from the GPU ({why}); the CPU runs it"); - } else if n == GPU_MAX_FALLBACKS { - log::warn!("denoise: the GPU got {n} tiles wrong; the CPU does the rest"); - } - } -} - -/// A remote server's free slot first (when there is one), then the card when this photo runs on it, with the CPU -/// running any tile either gets wrong. Handed out for photos that run on the card or have a server to share. -#[cfg(feature = "denoise")] -impl TileRunner for Network { - fn run(&self, input: &[f32]) -> Result, RunError> { - if let Some(Ok(remote)) = self.remote.as_ref().filter(|r| r.share.load(Ordering::Relaxed)).map(|r| r.usable(false)) - && let Some(Ok(out)) = remote.try_run(input) - { - return Ok(out); - } - if !self.local_card.load(Ordering::Relaxed) { - return self.cpu.run(input); - } - if let Ok(g) = self.card(false) { - match g.runner.run(input) { - Ok(out) if out.iter().all(|v| v.is_finite()) => return Ok(out), - Ok(_) => self.fell_back("numbers that are not finite"), - Err(e) => self.fell_back(&e.to_string()), - } - } - self.cpu.run(input) - } -} - -#[cfg(feature = "denoise")] -impl Model for Network { - fn bytes_per_tile(&self) -> usize { - self.cpu.bytes_per_tile() - } - fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) { - let threads = threads.max(1); - self.last_threads.store(threads, Ordering::Relaxed); - let (card, local) = match self.choose(run_on, threads, true) { - Ok(_) => (true, threads.min(GPU_PARALLEL)), - Err(_) => (false, threads), - }; - self.local_card.store(card, Ordering::Relaxed); - if let Some(r) = &self.remote { - // this computer's time for a tile: the card's, or the CPU's with `threads` tiles sharing it (as - // `card_is_faster` counts them); unknown when the card was never set up (then the server is used) - let local_ms = match self.gpu.get() { - Some(Ok(g)) if card => g.card_ms, - Some(Ok(g)) => g.cpu_ms / (1.0 + 0.5 * threads.saturating_sub(1) as f64), - _ => f64::INFINITY, - }; - let share = match (r.usable(true), r.tile_ms()) { - (Ok(_), Some(ms)) if ms < local_ms => { - *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = None; - true - } - (Ok(_), Some(ms)) => { - *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = Some((ms, local_ms)); - false - } - _ => false, - }; - r.share.store(share, Ordering::Relaxed); - if let (true, Ok(remote)) = (share, r.usable(false)) { - // the server's slots come on top of this computer's own tiles at once - return (self, local + remote.slots()); - } - } - if card { (self, local) } else { (&self.cpu, local) } - } - - fn self_test(&self, run_on: RunOn) -> Result { - let t = self.cpu.self_test(); - if !t.ok { - let failed: Vec<&str> = t.checks.iter().filter(|(_, ok)| !*ok).map(|(c, _)| c.as_str()).collect(); - return Err(format!("the model failed its self-test: it does not {}", failed.join(", and does not "))); - } - let mut v = serde_json::to_value(&t).map_err(|e| e.to_string())?; - // the card is set up and checked here, so the test says where the work will run - if run_on != RunOn::Cpu { - let _ = self.card(true); - } - if let Some(o) = v.as_object_mut() { - o.insert("device".into(), self.device(run_on)); - } - Ok(v) - } - - fn device(&self, run_on: RunOn) -> Value { - if run_on != RunOn::Cpu && self.gpu.get().is_none() { - return json!({"kind": "pending"}); - } - let threads = match self.last_threads.load(Ordering::Relaxed) { - 0 => Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None), - n => n, - }; - let mut v = match self.choose(run_on, threads, false) { - Ok(g) => json!({"kind": "gpu", "adapter": g.adapter}), - // a set-up that crashed or ran out of time can be tried again (`denoise.settings {runOn}`) - Err(why) => json!({"kind": "cpu", "retry": worth_retrying(&why), "reason": why}), - }; - if let (Some(Ok(g)), Some(o)) = (self.gpu.get(), v.as_object_mut()) { - // measured on this computer, on the check tile: the card's best run, and one tile alone on the CPU - o.insert("cardMs".into(), json!(g.card_ms)); - o.insert("cpuMs".into(), json!(g.cpu_ms)); - o.insert("fellBack".into(), json!(self.fallbacks.load(Ordering::Relaxed))); - } - if let (Some(r), Some(o)) = (&self.remote, v.as_object_mut()) { - o.insert("remote".into(), r.describe()); - } - v - } -} - -/// The loader of this build: our pure-Rust executor with the `denoise` feature, else one that says why nothing can run. -pub(crate) fn default_loader() -> Loader { - #[cfg(feature = "denoise")] - { - Arc::new(|path, manifest| { - // the server must run this very file: identified by the manifest's hash, else the file's - let sha256 = match &manifest.sha256 { - Some(h) => Some(h.clone()), - None if std::env::var_os("LIGHTCRAFT_DENOISE_URL").is_some() => lightcraft_denoise::hash::sha256_file(path).ok(), - None => None, - }; - let remote = sha256.and_then(|h| RemoteSide::from_env(&h, manifest.tile as usize)); - Ok(Arc::new(network(path, manifest, remote)?) as Arc) - }) - } - #[cfg(not(feature = "denoise"))] - { - Arc::new(|_, _| Err("this build cannot run denoise models".to_string())) - } -} - -/// The model at `path` on this computer, sharing tiles with `remote` when there is one. -#[cfg(feature = "denoise")] -fn network(path: &Path, manifest: &DenoiserManifest, remote: Option) -> Result { - let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?; - Ok(Network { - remote, - local_card: AtomicBool::new(true), - cpu, - path: path.to_path_buf(), - tile: manifest.tile as usize, - gpu: OnceLock::new(), - fallbacks: AtomicUsize::new(0), - last_threads: AtomicUsize::new(0), - }) -} - -/// A model ready to be served to other computers (`lightcraft-cli denoise-serve`). -#[cfg(feature = "denoise")] -pub struct Serving { - pub model: lightcraft_denoise::remote::ModelId, - pub tile: usize, - /// Where its tiles run here: the graphics card's name, or why the CPU does. - pub device: String, - pub run: lightcraft_denoise::remote::RunTile, -} - -/// The installed model `id` (default: the one selected in Settings) in the models folder `dir` (default: this user's), -/// set up to run on `run_on` as the app would: the card checked against the CPU first, the CPU when the card can't. -#[cfg(feature = "denoise")] -pub fn serving(dir: Option<&Path>, id: Option<&str>, run_on: RunOn) -> Result { - let dir = match dir { - Some(d) => d.to_path_buf(), - None => crate::config::default_denoise_models_dir().ok_or("no denoise models folder (set LIGHTCRAFT_DENOISE_MODELS)")?, - }; - let want = match id { - Some(i) => i.to_string(), - None => read_settings(&dir).model.ok_or_else(|| { - format!("no denoise model is selected in {} (install one in Settings ▸ AI Denoise, or name it with --model)", dir.display()) - })?, - }; - let installed = installed_models(&dir) - .into_iter() - .find(|m| m.manifest.id == want) - .ok_or_else(|| format!("no installed denoise model `{want}` in {}", dir.display()))?; - let m = &installed.manifest; - // the same identity a client derives (see `default_loader`) - let sha256 = match &m.sha256 { - Some(h) => h.clone(), - None => lightcraft_denoise::hash::sha256_file(&installed.onnx).map_err(|e| format!("{}: {e}", installed.onnx.display()))?, - }; - let net = Arc::new(network(&installed.onnx, m, None)?); - let tile = m.tile as usize; - // set the card up now (and say where the tiles run) rather than with the first client's tile - let _ = net.runner(run_on, GPU_PARALLEL); - let d = net.device(run_on); - let device = match d["kind"].as_str() { - Some("gpu") => format!("GPU: {}", d["adapter"].as_str().unwrap_or("?")), - _ => format!("CPU ({})", d["reason"].as_str().unwrap_or("chosen")), - }; - let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| net.runner(run_on, GPU_PARALLEL).0.run(input)); - Ok(Serving { model: lightcraft_denoise::remote::ModelId { id: m.id.clone(), version: m.version.clone(), sha256 }, tile, device, run }) -} - -/// A model that is loaded on first use and kept, shared by the background job and the exports. -pub(crate) type ModelSlot = Arc>>>; - -// --------------------------------------------------------------------------------------------------------------------- -// settings and installed models - -/// `settings.json` in the models folder. -#[derive(Clone, Default, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub(crate) struct Settings { - /// The installed model in use; `None`: denoise is off. - pub model: Option, - /// Photos with a Denoise amount above 0 that are being looked at (active, selected) get their picture made without - /// being asked (`None`: yes). - pub auto: Option, - /// Cache size limit (GB; `None`: [`DEFAULT_CACHE_GB`]). - pub cache_gb: Option, - /// Most tiles run at once (`None`: by how hard the pump says to work). - pub threads: Option, - /// Where the model runs: `auto`, `gpu` or `cpu` (`None` or anything else: `auto`; see [`RunOn`]). - pub run_on: Option, - /// What came before `runOn`: `false` was the processor only. - pub gpu: Option, -} - -impl Settings { - pub fn auto(&self) -> bool { - self.auto.unwrap_or(true) - } - - pub fn run_on(&self) -> RunOn { - match self.run_on.as_deref().and_then(RunOn::parse) { - Some(r) => r, - None if self.gpu == Some(false) => RunOn::Cpu, - None => RunOn::Auto, - } - } - - pub fn cache_bytes(&self) -> u64 { - u64::from(self.cache_gb.unwrap_or(DEFAULT_CACHE_GB).clamp(1, 10_000)) << 30 - } -} - -pub(crate) fn read_capped(path: &Path, max: usize) -> Option> { - use std::io::Read; - let mut buf = Vec::new(); - std::fs::File::open(path).ok()?.take(max as u64 + 1).read_to_end(&mut buf).ok()?; - (buf.len() <= max).then_some(buf) -} - -pub(crate) fn read_settings(dir: &Path) -> Settings { - read_capped(&dir.join("settings.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() -} - -/// Write a file next to its final name and rename it into place. -pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { - let part = path.with_extension("part"); - std::fs::write(&part, bytes).map_err(|e| format!("could not write {}: {e}", path.display()))?; - std::fs::rename(&part, path).map_err(|e| { - let _ = std::fs::remove_file(&part); - format!("could not write {}: {e}", path.display()) - }) -} - -pub(crate) fn write_settings(dir: &Path, st: &Settings) -> Result<(), String> { - std::fs::create_dir_all(dir).map_err(|e| format!("could not create the denoise models folder: {e}"))?; - write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| e.to_string())?) -} - -/// An installed model folder: `/model.onnx`, `denoise-model.json`, `installed.json`. -pub(crate) struct Installed { - pub manifest: DenoiserManifest, - pub onnx: PathBuf, - /// What was recorded when it was installed (terms accepted, hash of the file, self-test). - pub accepted: Value, -} - -impl Installed { - /// Names the model file the cache is made with: a hash of the file as installed, so another file with the same id - /// never finds this one's products. - pub fn fingerprint(&self) -> String { - let sha = self.accepted.get("sha256").and_then(Value::as_str).or(self.manifest.sha256.as_deref()).unwrap_or(""); - let len = std::fs::metadata(&self.onnx).map(|m| m.len()).unwrap_or(0); - format!("{}@{}:{len}", self.manifest.id, sha) - } -} - -/// The models in the folder. A folder that is not a valid model (wrong name, no model file, a manifest that does not -/// validate or does not match its folder) is ignored, never trusted. -pub(crate) fn installed_models(dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; - let mut out = Vec::new(); - for e in entries.flatten() { - let p = e.path(); - let Some(name) = p.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue }; - let onnx = p.join("model.onnx"); - if !lightcraft_denoise::licence::valid_id(&name) || !p.is_dir() || !onnx.is_file() { - continue; - } - let Some(m) = read_capped(&p.join("denoise-model.json"), lightcraft_denoise::manifest::MAX_MANIFEST_BYTES) - .and_then(|b| lightcraft_denoise::manifest::parse(&b).ok()) - else { - continue; - }; - if m.id != name { - continue; - } - let accepted = read_capped(&p.join("installed.json"), 8192).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null); - out.push(Installed { manifest: m, onnx, accepted }); - } - out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); - out -} - -/// Forget that setting the graphics card up for a model closed LightCraft before (the markers [`set_up_card`] leaves): -/// the user chose where denoise runs, so the card may be tried again. -pub(crate) fn forget_gpu_set_ups(dir: &Path) { - for i in installed_models(dir) { - let _ = std::fs::remove_file(i.onnx.with_file_name(GPU_SETUP_MARKER)); - } -} - -/// The model in use. -pub(crate) struct Active { - pub id: String, - pub manifest: DenoiserManifest, - pub onnx: PathBuf, - pub fingerprint: String, - pub model: ModelSlot, -} - -// --------------------------------------------------------------------------------------------------------------------- -// products - -/// The photos whose files can be denoised at all: raw files developed from their sensor data and read from disk. -pub(crate) fn eligible(p: &Photo) -> bool { - p.develops_raw() && matches!(p.source, Source::File { .. }) && !p.deleted -} - -/// A product's name: what it was made from and with. -pub(crate) fn product_key(fingerprint: &str, p: &Photo) -> String { - let mut h = Hasher128::new(); - h.str("lcdn").u64(u64::from(product::ALGORITHM)).str(fingerprint).str(&crate::media::content_key(p)); - h.finish().to_string() -} - -pub(crate) fn product_path(dir: &Path, key: &str) -> PathBuf { - dir.join(format!("{key}.{EXT}")) -} - -/// The `.lcdn` files in `dir`: path, size, last change. -fn products_in(dir: &Path) -> Vec<(PathBuf, u64, SystemTime)> { - let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; - entries - .flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == EXT)) - .filter_map(|e| { - let m = e.metadata().ok()?; - m.is_file().then(|| (e.path(), m.len(), m.modified().unwrap_or(SystemTime::UNIX_EPOCH))) - }) - .collect() -} - -/// Make sure only one thread writes a product at a time: a second one waits, then finds it made. -fn making() -> &'static (Mutex>, Condvar) { - static MAKING: OnceLock<(Mutex>, Condvar)> = OnceLock::new(); - MAKING.get_or_init(|| (Mutex::new(HashSet::new()), Condvar::new())) -} - -struct Slot(PathBuf); - -impl Slot { - fn enter(path: &Path) -> Slot { - let (set, cv) = making(); - let mut g = set.lock().unwrap_or_else(PoisonError::into_inner); - while g.contains(path) { - g = cv.wait(g).unwrap_or_else(PoisonError::into_inner); - } - g.insert(path.to_path_buf()); - Slot(path.to_path_buf()) - } -} - -impl Drop for Slot { - fn drop(&mut self) { - let (set, cv) = making(); - set.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.0); - cv.notify_all(); - } -} - -// --------------------------------------------------------------------------------------------------------------------- -// making a product - -/// Everything a thread needs to make one product, detached from the session. -#[derive(Clone)] -pub(crate) struct JobSpec { - /// The original file. - pub path: String, - pub read: Option, - pub product: PathBuf, - pub key: String, - pub manifest: DenoiserManifest, - pub onnx: PathBuf, - pub model: ModelSlot, - pub loader: Loader, - /// Tiles run at once. - pub parallel: usize, - /// Where the model runs (the setting). - pub run_on: RunOn, -} - -/// Why a product was not made. -#[derive(Debug, PartialEq)] -pub(crate) enum MakeError { - /// Denoise does not apply to this file (not a Bayer raw): nothing is wrong. - Unsupported(String), - Failed(String), - Cancelled, -} - -impl From for MakeError { - fn from(e: RunError) -> Self { - match e { - RunError::Cancelled => MakeError::Cancelled, - other => MakeError::Failed(other.to_string()), - } - } -} - -/// Progress and cancel of a running job. -#[derive(Default)] -pub(crate) struct Progress { - done: AtomicUsize, - total: AtomicUsize, - cancel: AtomicBool, -} - -impl Progress { - /// Ask the job to stop at the next tile. - pub(crate) fn cancel_now(&self) { - self.cancel.store(true, Ordering::Relaxed); - } -} - -/// The loaded model, loading it first when it is not (kept for the next photo). -fn loaded(spec: &JobSpec) -> Result, MakeError> { - if let Some(m) = spec.model.lock().unwrap_or_else(PoisonError::into_inner).clone() { - return Ok(m); - } - let m = (spec.loader)(&spec.onnx, &spec.manifest).map_err(|e| MakeError::Failed(format!("the denoise model could not be loaded: {e}")))?; - *spec.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(m.clone()); - Ok(m) -} - -/// The cell `[(0,0), (1,0), (0,1), (1,1)]` colours of a Bayer mosaic and so its layout. -fn layout_of(cfa: &lightcraft_raw::Cfa) -> Option { - Layout::from_cell([cfa.color_at(0, 0), cfa.color_at(1, 0), cfa.color_at(0, 1), cfa.color_at(1, 1)]) -} - -/// Make the product of `spec` unless it is there: decode the photo, run the model over its mosaic, write the result. -/// `Ok(true)` when it was made now, `Ok(false)` when it already existed. -pub(crate) fn make_product(spec: &JobSpec, progress: Option<&Progress>) -> Result { - let _slot = Slot::enter(&spec.product); - if product::is_current(&spec.product, &spec.key) { - return Ok(false); - } - let began = web_time::Instant::now(); - let cancelled = || progress.is_some_and(|p| p.cancel.load(Ordering::Relaxed)); - if cancelled() { - return Err(MakeError::Cancelled); - } - let bytes = match &spec.read { - Some(r) => r(&spec.path).map_err(MakeError::Failed)?, - None => std::fs::read(&spec.path).map_err(|e| MakeError::Failed(format!("{}: {e}", spec.path)))?, - }; - // the decode and the normalised mosaic wait their turn at the process-wide memory gate, as every decode does - let (mosaic, layout) = { - let _held = crate::memory::work_gate().acquire(bytes.len().saturating_mul(4)); - if lightcraft_raw::probe(&bytes).is_none() { - return Err(MakeError::Unsupported("not a raw file".into())); - } - let raw = match lightcraft_raw::decode(&bytes) { - Ok(r) => r, - Err(lightcraft_raw::RawError::Unsupported(why)) => return Err(MakeError::Unsupported(why)), - Err(e) => return Err(MakeError::Failed(e.to_string())), - }; - drop(bytes); - if raw.cpp != 1 || !raw.cfa.as_ref().is_some_and(lightcraft_raw::Cfa::is_bayer) { - return Err(MakeError::Unsupported( - "only Bayer raw files can be denoised (this one has another sensor layout or is already demosaiced)".into(), - )); - } - let n = raw.normalized().map_err(|e| MakeError::Failed(e.to_string()))?; - drop(raw); - let layout = - n.cfa.as_ref().and_then(layout_of).ok_or_else(|| MakeError::Unsupported("the colour filter layout is not a Bayer pattern".into()))?; - (n, layout) - }; - if cancelled() { - return Err(MakeError::Cancelled); - } - let decoded = began.elapsed(); - let model = loaded(spec)?; - // Packed input, returned RGB and the preceding group's RGB overlap while tiles run. - let scratch = - model.bytes_per_tile().saturating_add(128usize.saturating_mul(spec.manifest.tile as usize).saturating_mul(spec.manifest.tile as usize)); - let (_, budget) = crate::memory::work_gate().usage(); - let parallel = spec.parallel.clamp(1, 4).min((budget / scratch.max(1)).max(1)); - let _held = crate::memory::work_gate().acquire(mosaic.data.len().saturating_mul(20).saturating_add(scratch.saturating_mul(parallel))); - let (runner, parallel) = model.runner(spec.run_on, parallel); - let params = Params { - tile: spec.manifest.tile as usize, - overlap: spec.manifest.overlap as usize, - gain: spec.manifest.gain, - clip: Some(CLIP), - parallel: parallel.max(1), - }; - let report = |done: usize, total: usize| { - if let Some(p) = progress { - p.total.store(total, Ordering::Relaxed); - p.done.store(done, Ordering::Relaxed); - } - }; - let ctl = Control { cancel: progress.map(|p| &p.cancel), progress: Some(&report) }; - // the tiles run on threads of their own, so a busy window's renders keep theirs - let pool = rayon::ThreadPoolBuilder::new() - .num_threads(params.parallel) - .stack_size(STACK) - .thread_name(|i| format!("denoise-{i}")) - .build() - .map_err(|e| MakeError::Failed(format!("could not start the worker threads: {e}")))?; - let rgb = pool.install(|| denoise_bayer(&mosaic.data, mosaic.width, mosaic.height, layout, runner, ¶ms, &ctl))?; - drop(mosaic); - if cancelled() { - return Err(MakeError::Cancelled); - } - let ran = began.elapsed(); - product::write(&spec.product, &rgb, &spec.key).map_err(|e| MakeError::Failed(format!("could not save the denoised picture: {e}")))?; - if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { - eprintln!( - "[denoise] read + decode {:.0} ms, model (set-up, tiles, blend) {:.0} ms, write {:.0} ms", - decoded.as_secs_f64() * 1e3, - (ran - decoded).as_secs_f64() * 1e3, - (began.elapsed() - ran).as_secs_f64() * 1e3 - ); - } - Ok(true) -} - -// --------------------------------------------------------------------------------------------------------------------- -// the session's state - -/// How hard the background work may go (the app picks it from what the user is doing, like the face scan). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Pace { - /// Start nothing new. - Pause, - /// Two tiles at once. - Light, - /// Half the machine. - Normal, - /// Most of the machine: the user is watching the progress. - Full, -} - -impl Pace { - /// `pause`, `light`, `normal` or `full`; anything else is `normal`. - pub fn parse(name: Option<&str>) -> Pace { - match name { - Some("pause") => Pace::Pause, - Some("light") => Pace::Light, - Some("full") => Pace::Full, - _ => Pace::Normal, - } - } - - /// Tiles to run at once on a machine with `cores` threads (`cap`: the user's own limit). - pub fn parallel(self, cores: usize, cap: Option) -> usize { - let n = match self { - Pace::Pause | Pace::Light => 2, - Pace::Normal => cores / 2, - Pace::Full => cores * 4 / 5, - } - .clamp(1, 16); - cap.map_or(n, |c| n.min(c.max(1) as usize)) - } -} - -/// How a photo stands, for the interface. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "state", rename_all = "camelCase")] -pub enum PhotoState { - /// Not a photo denoise applies to (not a raw, offline, a demo). - NotApplicable, - /// No model is chosen: nothing can be made. - NoModel, - /// Its picture is made: the Amount slider works. - Ready, - Queued { - /// Photos ahead of it (0 = next). - ahead: usize, - }, - Running { - done: usize, - total: usize, - }, - /// Making it failed (`why`), or denoise does not apply to this file (`unsupported`). - Failed { - why: String, - unsupported: bool, - }, - /// Nothing has asked for it yet. - Idle, -} - -#[derive(Clone)] -struct Failure { - key: String, - why: String, - unsupported: bool, -} - -enum Outcome { - Done, - Failed(String, bool), - Cancelled, -} - -struct Running { - photo: PhotoId, - key: String, - product: PathBuf, - progress: Arc, - outcome: Arc>>, - started: web_time::Instant, -} - -pub(crate) struct State { - /// Where the models are kept (`None` where there is no file system). - pub models_dir: Option, - /// Models being downloaded at the user's request. - pub downloads: Downloads, - pub(crate) install: Option, - /// How a model is loaded (tests put another in). - pub loader: Loader, - pub(crate) active: Option, - active_seen: Option, - /// `settings.json` as last read (with the model choice). - pub(crate) settings: Settings, - /// The index must be made again (the library, the model or the cache changed). - dirty: bool, - /// The catalog revision and time of the last index, to look again when photos changed. - reindexed: Option<(u64, web_time::Instant)>, - queue: VecDeque, - running: Option, - failed: HashMap, - /// Counts every change of what is ready, so the interface knows to draw again. - pub generation: u64, - /// Photos made since the session started. - made: u64, - /// AI Denoise's row in the activity stack, while a photo is being made or waits its turn. - task: Option, -} - -impl Default for State { - fn default() -> Self { - State { - models_dir: None, - downloads: Downloads::default(), - install: None, - loader: default_loader(), - active: None, - active_seen: None, - settings: Settings::default(), - dirty: true, - reindexed: None, - queue: VecDeque::new(), - running: None, - failed: HashMap::new(), - generation: 0, - made: 0, - task: None, - } - } -} - -impl Drop for State { - /// Quitting stops the job instead of leaving it to finish a photo nobody will see. - fn drop(&mut self) { - if let Some(r) = &self.running { - r.progress.cancel_now(); - } - } -} - -impl State { - /// The library was replaced: nothing carries over (photo ids are per library). - pub fn library_changed(&mut self) { - if let Some(r) = self.running.take() { - r.progress.cancel_now(); - } - self.queue.clear(); - self.failed.clear(); - self.dirty = true; - self.generation += 1; - } - - /// The model choice or the cache changed on disk: look again at the next pump. - pub fn touch(&mut self) { - self.active_seen = None; - self.dirty = true; - } - - pub fn queued(&self) -> usize { - self.queue.len() - } -} - -impl Session { - /// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded. - pub(crate) fn denoise_device(&self) -> Value { - let loaded = self.denoise.active.as_ref().and_then(|a| a.model.lock().unwrap_or_else(PoisonError::into_inner).clone()); - match loaded { - Some(m) => m.device(self.denoise.settings.run_on()), - None => json!({"kind": "none"}), - } - } - - /// The folder the products of the open library are kept in; `None` without a library on disk. - pub(crate) fn denoise_products_dir(&self) -> Option { - self.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(DIR)) - } - - /// Read which model is in use (at most every couple of seconds): the manifest and file of the model the settings - /// name, if it is installed. - pub(crate) fn denoise_refresh_active(&mut self, force: bool) { - let fresh = self.denoise.active_seen.is_some_and(|t| t.elapsed() < SETTINGS_TTL); - if fresh && !force { - return; - } - self.denoise.active_seen = Some(web_time::Instant::now()); - self.denoise.settings = self.denoise.models_dir.as_deref().map(read_settings).unwrap_or_default(); - let chosen = self.denoise.models_dir.as_deref().and_then(|dir| { - let id = self.denoise.settings.model.clone()?; - installed_models(dir).into_iter().find(|i| i.manifest.id == id) - }); - let found = chosen.map(|i| (i.fingerprint(), i)); - let same = match (&self.denoise.active, &found) { - (None, None) => true, - (Some(a), Some((fp, _))) => a.fingerprint == *fp, - _ => false, - }; - if same { - return; - } - // another model: what was made with the old one is not this one's, and what failed may work now - self.denoise.active = found.map(|(fingerprint, i)| Active { - id: i.manifest.id.clone(), - manifest: i.manifest, - onnx: i.onnx, - fingerprint, - model: Arc::new(Mutex::new(None)), - }); - if let Some(r) = self.denoise.running.take() { - r.progress.cancel_now(); - } - self.denoise.queue.clear(); - self.denoise.failed.clear(); - self.denoise.dirty = true; - } - - fn denoise_spec(&self, p: &Photo) -> Option<(DenoiseSpec, String)> { - let (dir, active) = (self.denoise_products_dir()?, self.denoise.active.as_ref()?); - let key = product_key(&active.fingerprint, p); - Some((DenoiseSpec { product: product_path(&dir, &key), key: key.clone(), make: None }, key)) - } - - /// Make the index of ready photos from the products on disk (a directory listing and a hash per photo: no file is - /// opened). Pictures that appeared or vanished drop the decoded sources they affect. The photo being made is its - /// job's to report (see [`Self::denoise_check_photo`]). - pub(crate) fn denoise_reindex(&mut self) { - self.denoise.dirty = false; - self.denoise.reindexed = Some((self.catalog.revision, web_time::Instant::now())); - let running = self.denoise.running.as_ref().map(|r| r.photo); - let mut now: HashMap = HashMap::new(); - if let (Some(dir), Some(active)) = (self.denoise_products_dir(), self.denoise.active.as_ref()) { - let names: HashSet = - products_in(&dir).into_iter().filter_map(|(p, _, _)| p.file_stem().and_then(|s| s.to_str()).map(str::to_string)).collect(); - if !names.is_empty() { - for p in self.catalog.photos().filter(|p| eligible(p) && running != Some(p.id)) { - let key = product_key(&active.fingerprint, p); - if names.contains(&key) { - now.insert(p.id, DenoiseSpec { product: product_path(&dir, &key), key, make: None }); - } - } - } - } - let mut changed = false; - for id in self.media.denoise.ids() { - if !now.contains_key(&id) { - self.media.denoise.remove(id); - self.media.forget(id); - changed = true; - } - } - for (id, spec) in now { - if self.media.denoise.spec(id).is_none_or(|old| old.key != spec.key) { - self.media.denoise.set(id, spec); - self.media.forget(id); - changed = true; - } - } - if changed { - self.denoise.generation += 1; - } - } - - /// A photo whose file changed (its content key is not the one its index entry was made for) has no picture. - /// - /// The photo whose picture is being made is left to its job: the worker saves the picture before it reports, so the - /// file is on disk while the job still counts as running. Finding it here would make the photo Ready while the session - /// is busy with it (#719); [`Self::denoise_poll`] makes it Ready in the pump that takes the job's result instead. - fn denoise_check_photo(&mut self, id: PhotoId) { - if self.denoise.running.as_ref().is_some_and(|r| r.photo == id) { - return; - } - let Some(p) = self.catalog.photo(id).cloned() else { return }; - let Some((spec, _)) = self.denoise_spec(&p) else { return }; - match self.media.denoise.spec(id) { - Some(have) if have.key != spec.key => { - self.media.denoise.remove(id); - self.media.forget(id); - self.denoise.generation += 1; - } - None if spec.product.is_file() && product::is_current(&spec.product, &spec.key) => { - // made by an export, or by another session - self.media.denoise.set(id, spec); - self.media.forget(id); - self.denoise.generation += 1; - } - _ => {} - } - } - - /// How photo `id` stands. - pub fn denoise_photo_state(&self, id: PhotoId) -> PhotoState { - let Some(p) = self.catalog.photo(id) else { return PhotoState::NotApplicable }; - if !eligible(p) { - return PhotoState::NotApplicable; - } - if self.denoise.active.is_none() { - // the model folder has not been looked at yet (just started): not "no model" until it has been - return if self.denoise.active_seen.is_none() { PhotoState::Idle } else { PhotoState::NoModel }; - } - if self.denoise_products_dir().is_none() { - return PhotoState::Failed { - why: "AI Denoise keeps its pictures in the library folder, and this session has none.".into(), - unsupported: true, - }; - } - if self.media.denoise.spec(id).is_some() { - return PhotoState::Ready; - } - if let Some(r) = self.denoise.running.as_ref().filter(|r| r.photo == id) { - return PhotoState::Running { done: r.progress.done.load(Ordering::Relaxed), total: r.progress.total.load(Ordering::Relaxed) }; - } - if let Some(ahead) = self.denoise.queue.iter().position(|q| *q == id) { - return PhotoState::Queued { ahead: ahead + usize::from(self.denoise.running.is_some()) }; - } - let key = self.denoise_spec(p).map(|s| s.1); - match self.denoise.failed.get(&id) { - Some(f) if Some(&f.key) == key.as_ref() => PhotoState::Failed { why: f.why.clone(), unsupported: f.unsupported }, - _ => PhotoState::Idle, - } - } - - /// Ask for photos' pictures to be made (the ones that are not made, queued or known to be impossible). With `retry` - /// a photo that failed before is tried again. A photo that is not raw is skipped. Returns how many were queued. - pub(crate) fn denoise_enqueue(&mut self, ids: &[PhotoId], front: bool, retry: bool) -> usize { - self.denoise_refresh_active(false); - if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { - return 0; - } - let mut added = 0; - for id in ids { - let Some(p) = self.catalog.photo(*id).cloned() else { continue }; - if !eligible(&p) || self.media.denoise.spec(*id).is_some() || self.denoise.queue.contains(id) { - continue; - } - if self.denoise.running.as_ref().is_some_and(|r| r.photo == *id) || self.denoise.queue.len() >= MAX_QUEUE { - continue; - } - let Some((_, key)) = self.denoise_spec(&p) else { continue }; - if retry { - self.denoise.failed.remove(id); - } else if self.denoise.failed.get(id).is_some_and(|f| f.key == key) { - continue; - } - if front { - self.denoise.queue.push_front(*id); - } else { - self.denoise.queue.push_back(*id); - } - added += 1; - } - added - } - - /// Stop the job that is running, and with `all` forget everything that waits. - pub(crate) fn denoise_cancel(&mut self, photo: Option, all: bool) -> usize { - let mut n = 0; - if let Some(r) = &self.denoise.running - && photo.is_none_or(|p| p == r.photo) - { - r.progress.cancel_now(); - n += 1; - } - let before = self.denoise.queue.len(); - match photo { - Some(p) => self.denoise.queue.retain(|q| *q != p), - None if all => self.denoise.queue.clear(), - None => {} - } - n + before - self.denoise.queue.len() - } - - /// The photos the automatic queue looks at: the one open and the ones selected, those with a Denoise amount. - fn denoise_wanted(&self) -> Vec { - let Selection { active, ids, .. } = &self.selection; - active - .iter() - .chain(ids.iter().take(AUTO_LOOK)) - .copied() - .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) - .collect() - } - - fn denoise_job_spec(&self, p: &Photo, parallel: usize) -> Option { - let Source::File { path } = &p.source else { return None }; - let (spec, _) = self.denoise_spec(p)?; - let active = self.denoise.active.as_ref()?; - Some(JobSpec { - path: path.clone(), - read: self.media.file_bytes.clone(), - product: spec.product, - key: spec.key, - manifest: active.manifest.clone(), - onnx: active.onnx.clone(), - model: active.model.clone(), - loader: self.denoise.loader.clone(), - parallel, - run_on: self.denoise.settings.run_on(), - }) - } - - /// Start the next queued photo on its own thread. - fn denoise_start_next(&mut self, parallel: usize) { - while let Some(id) = self.denoise.queue.pop_front() { - let Some(p) = self.catalog.photo(id).cloned() else { continue }; - if !eligible(&p) || self.media.denoise.spec(id).is_some() { - continue; - } - let Some(spec) = self.denoise_job_spec(&p, parallel) else { continue }; - // another session or an export may have made it since it was queued - if product::is_current(&spec.product, &spec.key) { - self.denoise_check_photo(id); - continue; - } - let progress = Arc::new(Progress::default()); - let outcome: Arc>> = Arc::new(Mutex::new(None)); - let (key, product) = (spec.key.clone(), spec.product.clone()); - let (worker_progress, worker_outcome) = (progress.clone(), outcome.clone()); - let started = std::thread::Builder::new().name("denoise".into()).stack_size(STACK).spawn(move || { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_product(&spec, Some(&worker_progress)))); - let o = match result { - Ok(Ok(_)) => Outcome::Done, - Ok(Err(MakeError::Cancelled)) => Outcome::Cancelled, - Ok(Err(MakeError::Unsupported(why))) => Outcome::Failed(why, true), - Ok(Err(MakeError::Failed(why))) => Outcome::Failed(why, false), - Err(_) => Outcome::Failed("the denoiser stopped unexpectedly".into(), false), - }; - *worker_outcome.lock().unwrap_or_else(PoisonError::into_inner) = Some(o); - }); - match started { - Ok(_) => { - self.denoise.running = Some(Running { photo: id, key, product, progress, outcome, started: web_time::Instant::now() }); - } - Err(e) => { - self.denoise.failed.insert(id, Failure { key, why: format!("could not start the denoiser: {e}"), unsupported: false }); - continue; - } - } - return; - } - } - - /// Take in the running job's result. - fn denoise_poll(&mut self) { - let Some(r) = &self.denoise.running else { return }; - let Some(outcome) = r.outcome.lock().unwrap_or_else(PoisonError::into_inner).take() else { return }; - let Some(r) = self.denoise.running.take() else { return }; - match outcome { - Outcome::Done => { - self.denoise.made += 1; - self.denoise_check_photo(r.photo); - self.denoise_evict(Some(&r.product)); - } - Outcome::Failed(why, unsupported) => { - self.denoise.failed.insert(r.photo, Failure { key: r.key, why, unsupported }); - } - Outcome::Cancelled => {} - } - // the picture's state changed: the interface draws it again - self.denoise.generation += 1; - } - - /// Keep the cache within its limit: the pictures nobody asks for go first, the oldest first; `keep` (the one just - /// made) never goes. The index forgets what was deleted. - pub(crate) fn denoise_evict(&mut self, keep: Option<&Path>) { - let Some(models) = self.denoise.models_dir.clone() else { return }; - self.denoise_evict_to(read_settings(&models).cache_bytes(), keep); - } - - /// [`Self::denoise_evict`] for a limit of `limit` bytes. - pub(crate) fn denoise_evict_to(&mut self, limit: u64, keep: Option<&Path>) { - let Some(dir) = self.denoise_products_dir() else { return }; - let mut files = products_in(&dir); - let mut total: u64 = files.iter().map(|f| f.1).sum(); - if total <= limit { - return; - } - // the pictures photos use now (a Denoise amount above 0) are the last to go - let wanted: HashSet = self - .media - .denoise - .ids() - .into_iter() - .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) - .filter_map(|id| self.media.denoise.spec(id).map(|s| s.product.clone())) - .collect(); - files.sort_by_key(|(path, _, modified)| (wanted.contains(path), *modified)); - let mut gone: HashSet = HashSet::new(); - for (path, size, _) in files { - if total <= limit { - break; - } - if keep == Some(path.as_path()) { - continue; - } - if std::fs::remove_file(&path).is_ok() { - total = total.saturating_sub(size); - gone.insert(path); - } - } - if gone.is_empty() { - return; - } - for id in self.media.denoise.ids() { - if self.media.denoise.spec(id).is_some_and(|s| gone.contains(&s.product)) { - self.media.denoise.remove(id); - self.media.forget(id); - } - } - self.denoise.generation += 1; - } - - /// Delete every product of this library (they are made again when wanted). - pub(crate) fn denoise_clear(&mut self) -> (usize, u64) { - self.denoise_cancel(None, true); - let (mut files, mut bytes) = (0, 0); - if let Some(dir) = self.denoise_products_dir() { - for (path, size, _) in products_in(&dir) { - if std::fs::remove_file(&path).is_ok() { - files += 1; - bytes += size; - } - } - } - self.denoise.failed.clear(); - self.denoise.touch(); - (files, bytes) - } - - /// Per frame: take in what finished, look at what the user is looking at, and keep one job going. Cheap when - /// nothing is happening (no file is opened unless a photo with a Denoise amount lacks its picture). - pub(crate) fn denoise_pump(&mut self, pace: Pace) { - crate::cmd::denoise::finish_downloads(self); - self.denoise_refresh_active(false); - // photos that changed (a replaced file has another key) must not keep the picture of what they were - let stale = !self.media.denoise.is_empty() - && self.denoise.reindexed.is_none_or(|(rev, at)| rev != self.catalog.revision && at.elapsed() > SETTINGS_TTL); - if self.denoise.dirty || stale { - self.denoise_reindex(); - } - self.denoise_poll(); - if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { - return; - } - let (cap, auto) = (self.denoise.settings.threads, self.denoise.settings.auto()); - let wanted = self.denoise_wanted(); - for id in &wanted { - self.denoise_check_photo(*id); - } - if auto { - // the photo being edited goes first - let (open, others): (Vec, Vec) = wanted.into_iter().partition(|id| self.selection.active == Some(*id)); - self.denoise_enqueue(&others, false, false); - self.denoise_enqueue(&open, true, false); - } - if self.denoise.running.is_none() && pace != Pace::Pause { - let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); - self.denoise_start_next(pace.parallel(cores, cap)); - } - } - - /// The statistics `denoise.status` reports besides the queue (reads the cache folder). - pub(crate) fn denoise_cache_usage(&self) -> (usize, u64) { - match self.denoise_products_dir() { - Some(dir) => { - let files = products_in(&dir); - (files.len(), files.iter().map(|f| f.1).sum()) - } - None => (0, 0), - } - } - - /// Are pictures made without being asked for photos being looked at (Settings ▸ AI Denoise)? - pub fn denoise_auto(&self) -> bool { - self.denoise.settings.auto() - } - - /// Is a picture being made, or waiting for its turn with a model to make it? (Headless runs wait for this.) - pub fn denoise_busy(&self) -> bool { - self.denoise.running.is_some() || (!self.denoise.queue.is_empty() && self.denoise.active.is_some()) - } - - /// Keep AI Denoise's row in the activity stack up to date (each `denoise.pump`): there while a photo is being made or - /// waits its turn, with the running photo's tiles and its file name; gone when the queue is idle. It has no ✕: what is - /// made follows the photos' Denoise amounts and Settings ▸ AI Denoise. - pub(crate) fn denoise_track(&mut self) { - if !self.denoise_busy() { - self.denoise.task = None; - return; - } - let task = self.denoise.task.get_or_insert_with(|| { - let t = self.activity.start("denoise", "AI Denoise", crate::activity::Cancel::No); - t.set_unit(crate::activity::Unit::Percent); - t - }); - match &self.denoise.running { - Some(r) => { - task.progress(r.progress.done.load(Ordering::Relaxed) as u64, r.progress.total.load(Ordering::Relaxed) as u64); - task.detail(self.catalog.photo(r.photo).map_or("", |p| p.file_name.as_str())); - } - None => { - task.progress(0, 0); - task.detail(""); - } - } - } - - /// Seconds the running job has been going, and its progress. - pub(crate) fn denoise_running_json(&self) -> Value { - match &self.denoise.running { - Some(r) => json!({ - "photo": r.photo.0, - "done": r.progress.done.load(Ordering::Relaxed), - "total": r.progress.total.load(Ordering::Relaxed), - "seconds": r.started.elapsed().as_secs_f64(), - }), - None => Value::Null, - } - } - - pub(crate) fn denoise_made(&self) -> u64 { - self.denoise.made - } - - /// The running job's product path and key (tests stand in for the worker's save). - #[cfg(test)] - pub(crate) fn denoise_running_product(&self) -> Option<(PathBuf, String)> { - self.denoise.running.as_ref().map(|r| (r.product.clone(), r.key.clone())) - } - - /// What an export of photo `id` with `settings` needs for its Denoise amount: its denoised picture, made now when the - /// queue has not got to it. `Ok(None)` when denoise does not apply (amount 0, not a Bayer raw), `Err` when it should - /// have and cannot (no model chosen, nowhere to keep the picture, the model failing). - pub(crate) fn denoise_for_export(&mut self, id: PhotoId, settings: &DevelopSettings) -> Result, String> { - if settings.denoise_amount() <= 0.0 { - return Ok(None); - } - let Some(p) = self.catalog.photo(id).cloned() else { return Ok(None) }; - let Some(loader) = self.media.denoise.loader.clone() else { return Ok(None) }; - if !eligible(&p) { - return Ok(None); - } - self.denoise_refresh_active(false); - let name = &p.file_name; - if self.denoise.active.is_none() { - return Err(format!("{name}: Denoise is on for this photo but no denoise model is chosen; choose one in Settings, or set Denoise to 0")); - } - if self.denoise_products_dir().is_none() { - return Err(format!("{name}: Denoise needs a library on disk to keep its pictures in")); - } - self.denoise_check_photo(id); - let Some(job) = self.denoise_job_spec(&p, Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None)) else { - return Ok(None); - }; - let label = name.clone(); - let spec = job.clone(); - let make: MakeProduct = Arc::new(move || match make_product(&spec, None) { - Ok(_) => Ok(true), - Err(MakeError::Unsupported(_)) => Ok(false), - Err(MakeError::Failed(why)) => Err(format!("{label}: could not denoise: {why}")), - Err(MakeError::Cancelled) => Err(format!("{label}: denoise was cancelled")), - }); - Ok(Some((DenoiseSpec { product: job.product, key: job.key, make: Some(make) }, loader))) - } -} +pub use model::{Serving, serving}; +pub(crate) use products::eligible; +#[cfg(test)] +pub(crate) use products::{JobSpec, MakeError, Progress, make_product}; +pub use settings::{DEFAULT_CACHE_GB, DIR, EXT}; +pub(crate) use settings::{Settings, forget_gpu_set_ups, installed_models, read_capped, read_settings, write_atomic, write_settings}; +pub(crate) use state::State; +pub use state::{Pace, PhotoState}; #[cfg(all(test, feature = "denoise"))] -mod gpu_tests { - use super::*; - use lightcraft_denoise::manifest::{Domain, Gain}; - - /// A folder of the test's own: the card's set-up leaves its marker beside the model. - fn folder(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("lc-engine-gpu-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - /// The model file is read again when the card is set up (for the first photo), so it stays until the test ends. - fn model(name: &str, tile: u64) -> (Arc, lightcraft_denoise::runtime::CpuRunner, PathBuf) { - let path = folder(name).join("model.onnx"); - std::fs::write(&path, lightcraft_denoise::synthetic::unet_onnx(tile, 8, 2, 7)).unwrap(); - let manifest = DenoiserManifest { - id: "synthetic".into(), - name: "Synthetic".into(), - version: "1".into(), - licence: Default::default(), - source: None, - sha256: None, - size_bytes: None, - provenance: String::new(), - domain: Domain::BayerToRgb, - tile: tile as u32, - overlap: 16, - gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, - }; - let loaded = default_loader()(&path, &manifest).unwrap(); - let cpu = lightcraft_denoise::runtime::CpuRunner::load(&path, &manifest).unwrap(); - (loaded, cpu, path) - } - - fn worst(want: &[f32], got: &[f32]) -> f32 { - assert_eq!(want.len(), got.len()); - let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs())); - want.iter().zip(got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale - } - - #[test] - fn a_tile_on_the_card_is_the_cpus_tile() { - let (model, cpu, path) = model("same", 64); - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let want = cpu.run(&input).unwrap(); - let (runner, at_once) = model.runner(RunOn::Gpu, 16); - assert!(worst(&want, &runner.run(&input).unwrap()) < 1e-3); - let device = model.device(RunOn::Gpu); - eprintln!("device: {device}"); - match device["kind"].as_str() { - Some("gpu") => { - assert!(device["adapter"].as_str().is_some_and(|a| !a.is_empty())); - assert!(at_once <= GPU_PARALLEL, "a card is fed by a few tiles at once"); - assert!(device["cardMs"].as_f64().is_some_and(|ms| ms > 0.0) && device["cpuMs"].as_f64().is_some_and(|ms| ms > 0.0), "{device}"); - } - Some("cpu") => { - assert!(device["reason"].as_str().is_some_and(|r| !r.is_empty()), "the CPU is only used for a reason: {device}"); - assert_eq!(at_once, 16); - } - other => panic!("the first photo decides where the work runs, not {other:?}"), - } - assert!(!path.with_file_name(GPU_SETUP_MARKER).exists(), "a set-up that ended leaves no marker"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - /// A server on this computer running `cpu` for the model with `sha256`. - fn server(cpu: lightcraft_denoise::runtime::CpuRunner, sha256: &str, tile: usize) -> String { - use lightcraft_denoise::remote::{ModelId, ServerOptions, serve}; - let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = l.local_addr().unwrap().to_string(); - let id = ModelId { id: "synthetic".into(), version: "1".into(), sha256: sha256.into() }; - let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| cpu.run(input)); - let opts = ServerOptions { token: "remote-test-token-0123".into(), ..Default::default() }; - std::thread::spawn(move || serve(l, id, tile, "CPU (test server)".into(), run, opts, Arc::new(|_| {}))); - addr - } - - fn with_remote(path: &Path, tile: u64, addr: &str, sha256: &str) -> Network { - let manifest = DenoiserManifest { - id: "synthetic".into(), - name: "Synthetic".into(), - version: "1".into(), - licence: Default::default(), - source: None, - sha256: None, - size_bytes: None, - provenance: String::new(), - domain: Domain::BayerToRgb, - tile: tile as u32, - overlap: 16, - gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, - }; - let remote = RemoteSide::new(lightcraft_denoise::remote::Remote::new(addr, "remote-test-token-0123", sha256, tile as usize)); - network(path, &manifest, Some(remote)).unwrap() - } - - #[test] - fn a_remote_server_shares_the_tiles_and_drops_out_when_it_cannot() { - let (_, cpu, path) = model("remote", 64); - let sha = lightcraft_denoise::hash::sha256_file(&path).unwrap(); - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let want = cpu.run(&input).unwrap(); - // the same model on the server: its slots come on top of this computer's threads, answers within f16 rounding - let addr = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), &sha, 64); - let net = with_remote(&path, 64, &addr, &sha); - let (runner, at_once) = net.runner(RunOn::Cpu, 2); - assert_eq!(at_once, 2 + 4, "four server slots on top of two local threads"); - assert!(worst(&want, &runner.run(&input).unwrap()) < 5e-3); - let d = net.device(RunOn::Cpu); - assert_eq!(d["remote"]["state"], "used", "{d}"); - assert_eq!(d["remote"]["device"], "CPU (test server)"); - // a local card faster than the server there and back (here a stand-in that takes no time): nothing is shared, - // the photo never waits for the slower server, and the status says why - let mut net = with_remote(&path, 64, &addr, &sha); - net.gpu = OnceLock::from(Ok(GpuSide { runner: Box::new(cpu.clone()), adapter: "Fast card".into(), card_ms: 1e-6, cpu_ms: 1000.0 })); - let (_, at_once) = net.runner(RunOn::Gpu, 8); - assert_eq!(at_once, GPU_PARALLEL, "no server slots"); - let d = net.device(RunOn::Gpu); - assert_eq!(d["remote"]["state"], "slower", "{d}"); - assert!(d["remote"]["remoteMs"].as_f64().is_some_and(|ms| ms > 0.0) && d["remote"]["localMs"].as_f64().is_some(), "{d}"); - // a server running another model is refused: everything runs here - let other = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), "0000", 64); - let net = with_remote(&path, 64, &other, &sha); - let (runner, at_once) = net.runner(RunOn::Cpu, 2); - assert_eq!(at_once, 2); - assert_eq!(runner.run(&input).unwrap(), want, "the CPU path, bit for bit"); - let d = net.device(RunOn::Cpu); - assert!(d["remote"]["reason"].as_str().is_some_and(|r| r.contains("another model")), "{d}"); - // nobody listening: the same - let net = with_remote(&path, 64, "127.0.0.1:1", &sha); - assert_eq!(net.runner(RunOn::Cpu, 3).1, 3); - assert_eq!(net.device(RunOn::Cpu)["remote"]["state"], "unavailable"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn with_the_processor_chosen_everything_runs_on_the_cpu() { - let (model, cpu, path) = model("cpu", 64); - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let (runner, at_once) = model.runner(RunOn::Cpu, 16); - assert_eq!(runner.run(&input).unwrap(), cpu.run(&input).unwrap(), "the CPU path is the CPU runner, bit for bit"); - assert_eq!(at_once, 16, "the threads asked for are the threads used"); - assert_eq!(model.device(RunOn::Cpu)["kind"], "cpu"); - assert_eq!(model.device(RunOn::Auto)["kind"], "pending", "the card was not even set up"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn the_card_is_used_where_it_is_faster_than_the_processor() { - // measured: a tile alone on one CPU thread 1.4 s; an RTX 4090 Laptop 13 ms; a Radeon 610M 490 ms - for threads in [1, 2, 8, 16, 64] { - assert!(card_is_faster(13.0, 1400.0, threads), "a big card wins at {threads}"); - } - assert!(card_is_faster(490.0, 1400.0, 2), "a small card beats the two threads background work gets"); - assert!(!card_is_faster(490.0, 1400.0, 16), "and loses to sixteen"); - assert!(!card_is_faster(f64::INFINITY, 1400.0, 1) && !card_is_faster(f64::NAN, 1400.0, 1), "a card that never ran is never faster"); - assert!(!card_is_faster(10.0, 0.0, 1)); - } - - /// A card that gives numbers that are not finite. - struct Garbage(AtomicUsize); - - impl TileRunner for Garbage { - fn run(&self, input: &[f32]) -> Result, RunError> { - self.0.fetch_add(1, Ordering::Relaxed); - let n = input.len() / 4 * 12; - Ok(vec![f32::NAN; n]) - } - } - - #[test] - fn a_tile_the_card_gets_wrong_is_run_on_the_processor_and_three_stop_the_card() { - let (_, cpu, path) = model("garbage", 64); - let card = GpuSide { runner: Box::new(Garbage(AtomicUsize::new(0))), adapter: "Test card".into(), card_ms: 1.0, cpu_ms: 1000.0 }; - let model = Network { - cpu: cpu.clone(), - path: path.clone(), - tile: 32, - gpu: OnceLock::from(Ok(card)), - fallbacks: AtomicUsize::new(0), - last_threads: AtomicUsize::new(0), - remote: None, - local_card: AtomicBool::new(true), - }; - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let want = cpu.run(&input).unwrap(); - let (runner, at_once) = model.runner(RunOn::Auto, 8); - assert_eq!(at_once, GPU_PARALLEL, "the (fast) card is chosen"); - for _ in 0..GPU_MAX_FALLBACKS { - assert_eq!(runner.run(&input).unwrap(), want, "a wrong tile is the CPU's tile"); - } - let device = model.device(RunOn::Auto); - assert_eq!(device["kind"], "cpu", "{device}"); - assert!(device["reason"].as_str().unwrap().contains("tiles wrong"), "{device}"); - assert_eq!(device["fellBack"], GPU_MAX_FALLBACKS); - let (_, at_once) = model.runner(RunOn::Gpu, 8); - assert_eq!(at_once, 8, "the next photo runs on the CPU, even with the card chosen"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn a_slow_card_is_left_out_unless_it_is_chosen() { - let (_, cpu, path) = model("slow", 64); - let slow = GpuSide { runner: Box::new(cpu.clone()), adapter: "Slow card".into(), card_ms: 490.0, cpu_ms: 1400.0 }; - let model = Network { - cpu, - path: path.clone(), - tile: 32, - gpu: OnceLock::from(Ok(slow)), - fallbacks: AtomicUsize::new(0), - last_threads: AtomicUsize::new(0), - remote: None, - local_card: AtomicBool::new(true), - }; - assert_eq!(model.runner(RunOn::Auto, 16).1, 16, "sixteen threads beat it"); - let device = model.device(RunOn::Auto); - assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some("it is faster than Slow card on this computer"))); - assert_eq!(device["cardMs"], 490.0, "the times measured are reported"); - assert_eq!(device["retry"], false, "trying again would not make it faster"); - assert_eq!(model.runner(RunOn::Auto, 2).1, GPU_PARALLEL.min(2), "two threads do not"); - assert_eq!(model.device(RunOn::Auto)["kind"], "gpu"); - assert_eq!(model.runner(RunOn::Gpu, 16).1, GPU_PARALLEL, "chosen, it is used anyway"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn a_set_up_marker_left_behind_keeps_the_card_off() { - let (model, _, path) = model("crashed", 64); - let marker = path.with_file_name(GPU_SETUP_MARKER); - std::fs::write(&marker, "setting up the graphics card for AI Denoise (unix time 0)").unwrap(); - let (_, at_once) = model.runner(RunOn::Gpu, 4); - assert_eq!(at_once, 4, "the CPU does the work"); - let device = model.device(RunOn::Gpu); - assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some(GPU_CRASHED)), "{device}"); - assert_eq!(device["retry"], true, "the interface offers to try again"); - assert!(marker.exists(), "it stays until the user chooses where denoise runs"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn a_set_up_runs_beside_the_cpu_and_removes_its_marker() { - let dir = folder("setup"); - let marker = dir.join(GPU_SETUP_MARKER); - let seen = Arc::new(AtomicBool::new(false)); - let (m, s) = (marker.clone(), seen.clone()); - let r = set_up_card( - &marker, - Duration::from_secs(10), - move || { - s.store(m.exists(), Ordering::Relaxed); - Ok(7) - }, - || "cpu", - ); - assert_eq!(r, Ok((7, "cpu"))); - assert!(seen.load(Ordering::Relaxed), "the marker is there while the card is set up"); - assert!(!marker.exists(), "and gone after"); - // a set-up that fails or panics removes it too - assert_eq!(set_up_card(&marker, Duration::from_secs(10), || Err::<(), _>("no card".into()), || ()), Err("no card".into())); - assert!(!marker.exists()); - let r = set_up_card(&marker, Duration::from_secs(10), || -> Result<(), String> { panic!("driver") }, || ()); - assert!(r.is_err() && !marker.exists(), "{r:?}"); - // a marker found before the set-up starts means the last one took LightCraft down: the card is not touched - std::fs::write(&marker, "left behind").unwrap(); - let touched = Arc::new(AtomicBool::new(false)); - let t = touched.clone(); - let r = set_up_card( - &marker, - Duration::from_secs(10), - move || { - t.store(true, Ordering::Relaxed); - Ok(()) - }, - || (), - ); - assert_eq!(r, Err(GPU_CRASHED.to_string())); - assert!(!touched.load(Ordering::Relaxed) && marker.exists()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_set_up_that_takes_too_long_is_given_up_on() { - let dir = folder("slowsetup"); - let marker = dir.join(GPU_SETUP_MARKER); - let started = web_time::Instant::now(); - let slow = || -> Result<(), String> { - std::thread::sleep(Duration::from_millis(600)); - Ok(()) - }; - let r = set_up_card(&marker, Duration::from_millis(50), slow, || ()); - assert!(r.as_ref().is_err_and(|e| e.contains("took longer") && worth_retrying(e)), "{r:?}"); - assert!(started.elapsed() < Duration::from_millis(500), "the caller does not wait for the card"); - // while the first is still stuck, another set-up of the same model does not start a second one - let again = set_up_card(&marker, Duration::from_secs(5), || Ok(()), || ()); - assert!(again.is_err_and(|e| e.contains("still setting up") && worth_retrying(&e))); - // once it ends its marker goes, and the card can be set up again - while marker.exists() { - assert!(started.elapsed() < Duration::from_secs(10), "the stuck set-up ended"); - std::thread::sleep(Duration::from_millis(20)); - } - while SETTING_UP.lock().unwrap().iter().any(|m| m == &marker) { - std::thread::sleep(Duration::from_millis(5)); - } - assert_eq!(set_up_card(&marker, Duration::from_secs(5), || Ok(1), || 2), Ok((1, 2))); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod gpu_tests; diff --git a/crates/engine/src/denoise/gpu.rs b/crates/engine/src/denoise/gpu.rs new file mode 100644 index 000000000..1577b4edc --- /dev/null +++ b/crates/engine/src/denoise/gpu.rs @@ -0,0 +1,152 @@ +#[cfg(feature = "denoise")] +use super::model::{GpuSide, STACK}; +#[cfg(feature = "denoise")] +use super::*; + +/// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended. +#[cfg(feature = "denoise")] +pub(super) const GPU_PARALLEL: usize = 4; + +/// How far the GPU's answer on the check tile may be from the CPU's (of the answer's largest value) before it is not used. +#[cfg(feature = "denoise")] +const GPU_AGREE: f32 = 1e-3; + +/// Longest the card may take to set up (build its kernels, run the check tile, time it) before the processor does the +/// work instead. +#[cfg(feature = "denoise")] +const GPU_SETUP_LIMIT: Duration = Duration::from_secs(30); + +/// Tiles the card may get wrong (an error, numbers that are not finite) before it stops being used for the model. +#[cfg(feature = "denoise")] +pub(super) const GPU_MAX_FALLBACKS: usize = 3; + +/// The file beside a model while the graphics card is set up for it (see [`set_up_card`]). +pub(crate) const GPU_SETUP_MARKER: &str = "gpu-setup.marker"; + +/// Why the card is not tried when a set-up marker is found. +#[cfg(feature = "denoise")] +pub(super) const GPU_CRASHED: &str = "LightCraft closed while it was setting up the graphics card for AI Denoise last time"; + +/// Why the card is not used when its set-up ran out of time (the start of it). +#[cfg(feature = "denoise")] +pub(super) const GPU_SLOW: &str = "setting up the graphics card took longer than"; + +/// Why the card is not used while a set-up that ran out of time is still going. +#[cfg(feature = "denoise")] +pub(super) const GPU_STUCK: &str = "the graphics card is still setting up from before (it took too long)"; + +/// Whether the card (`card_ms` a tile) is faster than the CPU running `threads` tiles at once, from the CPU's time for +/// one tile alone (`cpu_ms`). Tiles at once share the memory and the cores' second threads, so each one more counts as +/// half a tile's speed: on a 16-core laptop one tile alone took 1.4 s and sixteen at once finished one every 0.2 s. +#[cfg(feature = "denoise")] +pub(super) fn card_is_faster(card_ms: f64, cpu_ms: f64, threads: usize) -> bool { + let at_once = 1.0 + 0.5 * threads.saturating_sub(1) as f64; + card_ms.is_finite() && card_ms <= cpu_ms / at_once +} + +/// Card set-ups of this process that have not finished, by marker (their markers are not a crash's). +#[cfg(feature = "denoise")] +pub(super) static SETTING_UP: Mutex> = Mutex::new(Vec::new()); + +/// Removes a set-up's marker when the set-up ends, however it ends — but not when the process dies inside the driver. +#[cfg(feature = "denoise")] +struct SetUpEnded(PathBuf); + +#[cfg(feature = "denoise")] +impl Drop for SetUpEnded { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner).retain(|m| m != &self.0); + } +} + +/// Set the graphics card up with `card` on a thread of its own while `meanwhile` runs here, and give up on the card when +/// it takes longer than `limit` (a driver building kernels for minutes, or stuck). The `marker` file exists while `card` +/// runs: one found before it starts was left by a LightCraft that closed during a set-up (a driver crash takes the app +/// with it), so the card is not tried again until the user chooses where denoise runs (`denoise.settings {runOn}`). +#[cfg(feature = "denoise")] +pub(super) fn set_up_card( + marker: &Path, + limit: Duration, + card: impl FnOnce() -> Result + Send + 'static, + meanwhile: impl FnOnce() -> R, +) -> Result<(T, R), String> { + let ended = { + let mut running = SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner); + if running.iter().any(|m| m == marker) { + return Err(GPU_STUCK.into()); + } + if marker.exists() { + return Err(GPU_CRASHED.into()); + } + let secs = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + // a marker that cannot be written only loses the crash guard + let _ = std::fs::write(marker, format!("setting up the graphics card for AI Denoise (unix time {secs})\n")); + running.push(marker.to_path_buf()); + SetUpEnded(marker.to_path_buf()) + }; + let started = web_time::Instant::now(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("denoise-gpu-setup".into()) + .stack_size(STACK) + .spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(card)).unwrap_or_else(|_| Err("the GPU runner gave up".into())); + drop(ended); + let _ = tx.send(r); + }) + .map_err(|e| format!("could not start setting up the graphics card: {e}"))?; + let here = meanwhile(); + match rx.recv_timeout(limit.saturating_sub(started.elapsed())) { + Ok(r) => r.map(|t| (t, here)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(format!("{GPU_SLOW} {} s, so it is not used", limit.as_secs_f64())), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err("the GPU runner gave up".into()), + } +} + +/// The card's time for one tile once it is warm: the fastest of up to three runs, stopping after about two seconds (a +/// slow card is slow enough to tell at once). +#[cfg(feature = "denoise")] +fn card_tile_ms(runner: &dyn TileRunner, input: &[f32]) -> f64 { + if runner.run(input).is_err() { + return f64::INFINITY; + } + let began = web_time::Instant::now(); + let mut best = f64::INFINITY; + for _ in 0..3 { + let one = web_time::Instant::now(); + if runner.run(input).is_err() { + break; + } + best = best.min(one.elapsed().as_secs_f64() * 1000.0); + if began.elapsed() > Duration::from_secs(2) { + break; + } + } + best +} + +/// Set the card up for the model at `path`: kernels built, the check tile run on both and compared, both timed. +#[cfg(feature = "denoise")] +pub(super) fn make_gpu(cpu: &lightcraft_denoise::runtime::CpuRunner, path: &Path, tile: usize) -> Result { + let input = Arc::new(lightcraft_denoise::runtime::check_tile(tile)); + let (on_card, net) = (input.clone(), cpu.net().clone()); + let card = move || -> Result<_, String> { + let runner = lightcraft_gpu::nn::runner(&net, tile)?; + let got = runner.run(&on_card).map_err(|e| format!("the GPU could not run the check tile: {e}"))?; + let card_ms = card_tile_ms(&runner, &on_card); + Ok((runner, got, card_ms)) + }; + let on_cpu = || { + let started = web_time::Instant::now(); + (cpu.run(&input), started.elapsed().as_secs_f64() * 1000.0) + }; + let ((runner, got, card_ms), (want, cpu_ms)) = set_up_card(&path.with_file_name(GPU_SETUP_MARKER), GPU_SETUP_LIMIT, card, on_cpu)?; + let want = want.map_err(|e| format!("the CPU could not check the GPU: {e}"))?; + let scale = want.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-12); + let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale; + if got.len() != want.len() || !got.iter().all(|v| v.is_finite()) || worst > GPU_AGREE { + return Err(format!("its answer on the check tile is not the CPU's (off by {worst:.1e} of the largest value): not used")); + } + Ok(GpuSide { adapter: runner.adapter(), runner: Box::new(runner), card_ms, cpu_ms }) +} diff --git a/crates/engine/src/denoise/gpu_tests.rs b/crates/engine/src/denoise/gpu_tests.rs new file mode 100644 index 000000000..bb80cecda --- /dev/null +++ b/crates/engine/src/denoise/gpu_tests.rs @@ -0,0 +1,306 @@ +use super::*; +use lightcraft_denoise::manifest::{Domain, Gain}; + +/// A folder of the test's own: the card's set-up leaves its marker beside the model. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-engine-gpu-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// The model file is read again when the card is set up (for the first photo), so it stays until the test ends. +fn model(name: &str, tile: u64) -> (Arc, lightcraft_denoise::runtime::CpuRunner, PathBuf) { + let path = folder(name).join("model.onnx"); + std::fs::write(&path, lightcraft_denoise::synthetic::unet_onnx(tile, 8, 2, 7)).unwrap(); + let manifest = DenoiserManifest { + id: "synthetic".into(), + name: "Synthetic".into(), + version: "1".into(), + licence: Default::default(), + source: None, + sha256: None, + size_bytes: None, + provenance: String::new(), + domain: Domain::BayerToRgb, + tile: tile as u32, + overlap: 16, + gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, + }; + let loaded = default_loader()(&path, &manifest).unwrap(); + let cpu = lightcraft_denoise::runtime::CpuRunner::load(&path, &manifest).unwrap(); + (loaded, cpu, path) +} + +fn worst(want: &[f32], got: &[f32]) -> f32 { + assert_eq!(want.len(), got.len()); + let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs())); + want.iter().zip(got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale +} + +#[test] +fn a_tile_on_the_card_is_the_cpus_tile() { + let (model, cpu, path) = model("same", 64); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let want = cpu.run(&input).unwrap(); + let (runner, at_once) = model.runner(RunOn::Gpu, 16); + assert!(worst(&want, &runner.run(&input).unwrap()) < 1e-3); + let device = model.device(RunOn::Gpu); + eprintln!("device: {device}"); + match device["kind"].as_str() { + Some("gpu") => { + assert!(device["adapter"].as_str().is_some_and(|a| !a.is_empty())); + assert!(at_once <= GPU_PARALLEL, "a card is fed by a few tiles at once"); + assert!(device["cardMs"].as_f64().is_some_and(|ms| ms > 0.0) && device["cpuMs"].as_f64().is_some_and(|ms| ms > 0.0), "{device}"); + } + Some("cpu") => { + assert!(device["reason"].as_str().is_some_and(|r| !r.is_empty()), "the CPU is only used for a reason: {device}"); + assert_eq!(at_once, 16); + } + other => panic!("the first photo decides where the work runs, not {other:?}"), + } + assert!(!path.with_file_name(GPU_SETUP_MARKER).exists(), "a set-up that ended leaves no marker"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +/// A server on this computer running `cpu` for the model with `sha256`. +fn server(cpu: lightcraft_denoise::runtime::CpuRunner, sha256: &str, tile: usize) -> String { + use lightcraft_denoise::remote::{ModelId, ServerOptions, serve}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap().to_string(); + let id = ModelId { id: "synthetic".into(), version: "1".into(), sha256: sha256.into() }; + let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| cpu.run(input)); + let opts = ServerOptions { token: "remote-test-token-0123".into(), ..Default::default() }; + std::thread::spawn(move || serve(l, id, tile, "CPU (test server)".into(), run, opts, Arc::new(|_| {}))); + addr +} + +fn with_remote(path: &Path, tile: u64, addr: &str, sha256: &str) -> Network { + let manifest = DenoiserManifest { + id: "synthetic".into(), + name: "Synthetic".into(), + version: "1".into(), + licence: Default::default(), + source: None, + sha256: None, + size_bytes: None, + provenance: String::new(), + domain: Domain::BayerToRgb, + tile: tile as u32, + overlap: 16, + gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, + }; + let remote = RemoteSide::new(lightcraft_denoise::remote::Remote::new(addr, "remote-test-token-0123", sha256, tile as usize)); + network(path, &manifest, Some(remote)).unwrap() +} + +#[test] +fn a_remote_server_shares_the_tiles_and_drops_out_when_it_cannot() { + let (_, cpu, path) = model("remote", 64); + let sha = lightcraft_denoise::hash::sha256_file(&path).unwrap(); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let want = cpu.run(&input).unwrap(); + // the same model on the server: its slots come on top of this computer's threads, answers within f16 rounding + let addr = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), &sha, 64); + let net = with_remote(&path, 64, &addr, &sha); + let (runner, at_once) = net.runner(RunOn::Cpu, 2); + assert_eq!(at_once, 2 + 4, "four server slots on top of two local threads"); + assert!(worst(&want, &runner.run(&input).unwrap()) < 5e-3); + let d = net.device(RunOn::Cpu); + assert_eq!(d["remote"]["state"], "used", "{d}"); + assert_eq!(d["remote"]["device"], "CPU (test server)"); + // a local card faster than the server there and back (here a stand-in that takes no time): nothing is shared, + // the photo never waits for the slower server, and the status says why + let mut net = with_remote(&path, 64, &addr, &sha); + net.gpu = OnceLock::from(Ok(GpuSide { runner: Box::new(cpu.clone()), adapter: "Fast card".into(), card_ms: 1e-6, cpu_ms: 1000.0 })); + let (_, at_once) = net.runner(RunOn::Gpu, 8); + assert_eq!(at_once, GPU_PARALLEL, "no server slots"); + let d = net.device(RunOn::Gpu); + assert_eq!(d["remote"]["state"], "slower", "{d}"); + assert!(d["remote"]["remoteMs"].as_f64().is_some_and(|ms| ms > 0.0) && d["remote"]["localMs"].as_f64().is_some(), "{d}"); + // a server running another model is refused: everything runs here + let other = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), "0000", 64); + let net = with_remote(&path, 64, &other, &sha); + let (runner, at_once) = net.runner(RunOn::Cpu, 2); + assert_eq!(at_once, 2); + assert_eq!(runner.run(&input).unwrap(), want, "the CPU path, bit for bit"); + let d = net.device(RunOn::Cpu); + assert!(d["remote"]["reason"].as_str().is_some_and(|r| r.contains("another model")), "{d}"); + // nobody listening: the same + let net = with_remote(&path, 64, "127.0.0.1:1", &sha); + assert_eq!(net.runner(RunOn::Cpu, 3).1, 3); + assert_eq!(net.device(RunOn::Cpu)["remote"]["state"], "unavailable"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn with_the_processor_chosen_everything_runs_on_the_cpu() { + let (model, cpu, path) = model("cpu", 64); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let (runner, at_once) = model.runner(RunOn::Cpu, 16); + assert_eq!(runner.run(&input).unwrap(), cpu.run(&input).unwrap(), "the CPU path is the CPU runner, bit for bit"); + assert_eq!(at_once, 16, "the threads asked for are the threads used"); + assert_eq!(model.device(RunOn::Cpu)["kind"], "cpu"); + assert_eq!(model.device(RunOn::Auto)["kind"], "pending", "the card was not even set up"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn the_card_is_used_where_it_is_faster_than_the_processor() { + // measured: a tile alone on one CPU thread 1.4 s; an RTX 4090 Laptop 13 ms; a Radeon 610M 490 ms + for threads in [1, 2, 8, 16, 64] { + assert!(card_is_faster(13.0, 1400.0, threads), "a big card wins at {threads}"); + } + assert!(card_is_faster(490.0, 1400.0, 2), "a small card beats the two threads background work gets"); + assert!(!card_is_faster(490.0, 1400.0, 16), "and loses to sixteen"); + assert!(!card_is_faster(f64::INFINITY, 1400.0, 1) && !card_is_faster(f64::NAN, 1400.0, 1), "a card that never ran is never faster"); + assert!(!card_is_faster(10.0, 0.0, 1)); +} + +/// A card that gives numbers that are not finite. +struct Garbage(AtomicUsize); + +impl TileRunner for Garbage { + fn run(&self, input: &[f32]) -> Result, RunError> { + self.0.fetch_add(1, Ordering::Relaxed); + let n = input.len() / 4 * 12; + Ok(vec![f32::NAN; n]) + } +} + +#[test] +fn a_tile_the_card_gets_wrong_is_run_on_the_processor_and_three_stop_the_card() { + let (_, cpu, path) = model("garbage", 64); + let card = GpuSide { runner: Box::new(Garbage(AtomicUsize::new(0))), adapter: "Test card".into(), card_ms: 1.0, cpu_ms: 1000.0 }; + let model = Network { + cpu: cpu.clone(), + path: path.clone(), + tile: 32, + gpu: OnceLock::from(Ok(card)), + fallbacks: AtomicUsize::new(0), + last_threads: AtomicUsize::new(0), + remote: None, + local_card: AtomicBool::new(true), + }; + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let want = cpu.run(&input).unwrap(); + let (runner, at_once) = model.runner(RunOn::Auto, 8); + assert_eq!(at_once, GPU_PARALLEL, "the (fast) card is chosen"); + for _ in 0..GPU_MAX_FALLBACKS { + assert_eq!(runner.run(&input).unwrap(), want, "a wrong tile is the CPU's tile"); + } + let device = model.device(RunOn::Auto); + assert_eq!(device["kind"], "cpu", "{device}"); + assert!(device["reason"].as_str().unwrap().contains("tiles wrong"), "{device}"); + assert_eq!(device["fellBack"], GPU_MAX_FALLBACKS); + let (_, at_once) = model.runner(RunOn::Gpu, 8); + assert_eq!(at_once, 8, "the next photo runs on the CPU, even with the card chosen"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_slow_card_is_left_out_unless_it_is_chosen() { + let (_, cpu, path) = model("slow", 64); + let slow = GpuSide { runner: Box::new(cpu.clone()), adapter: "Slow card".into(), card_ms: 490.0, cpu_ms: 1400.0 }; + let model = Network { + cpu, + path: path.clone(), + tile: 32, + gpu: OnceLock::from(Ok(slow)), + fallbacks: AtomicUsize::new(0), + last_threads: AtomicUsize::new(0), + remote: None, + local_card: AtomicBool::new(true), + }; + assert_eq!(model.runner(RunOn::Auto, 16).1, 16, "sixteen threads beat it"); + let device = model.device(RunOn::Auto); + assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some("it is faster than Slow card on this computer"))); + assert_eq!(device["cardMs"], 490.0, "the times measured are reported"); + assert_eq!(device["retry"], false, "trying again would not make it faster"); + assert_eq!(model.runner(RunOn::Auto, 2).1, GPU_PARALLEL.min(2), "two threads do not"); + assert_eq!(model.device(RunOn::Auto)["kind"], "gpu"); + assert_eq!(model.runner(RunOn::Gpu, 16).1, GPU_PARALLEL, "chosen, it is used anyway"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_set_up_marker_left_behind_keeps_the_card_off() { + let (model, _, path) = model("crashed", 64); + let marker = path.with_file_name(GPU_SETUP_MARKER); + std::fs::write(&marker, "setting up the graphics card for AI Denoise (unix time 0)").unwrap(); + let (_, at_once) = model.runner(RunOn::Gpu, 4); + assert_eq!(at_once, 4, "the CPU does the work"); + let device = model.device(RunOn::Gpu); + assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some(GPU_CRASHED)), "{device}"); + assert_eq!(device["retry"], true, "the interface offers to try again"); + assert!(marker.exists(), "it stays until the user chooses where denoise runs"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_set_up_runs_beside_the_cpu_and_removes_its_marker() { + let dir = folder("setup"); + let marker = dir.join(GPU_SETUP_MARKER); + let seen = Arc::new(AtomicBool::new(false)); + let (m, s) = (marker.clone(), seen.clone()); + let r = set_up_card( + &marker, + Duration::from_secs(10), + move || { + s.store(m.exists(), Ordering::Relaxed); + Ok(7) + }, + || "cpu", + ); + assert_eq!(r, Ok((7, "cpu"))); + assert!(seen.load(Ordering::Relaxed), "the marker is there while the card is set up"); + assert!(!marker.exists(), "and gone after"); + // a set-up that fails or panics removes it too + assert_eq!(set_up_card(&marker, Duration::from_secs(10), || Err::<(), _>("no card".into()), || ()), Err("no card".into())); + assert!(!marker.exists()); + let r = set_up_card(&marker, Duration::from_secs(10), || -> Result<(), String> { panic!("driver") }, || ()); + assert!(r.is_err() && !marker.exists(), "{r:?}"); + // a marker found before the set-up starts means the last one took LightCraft down: the card is not touched + std::fs::write(&marker, "left behind").unwrap(); + let touched = Arc::new(AtomicBool::new(false)); + let t = touched.clone(); + let r = set_up_card( + &marker, + Duration::from_secs(10), + move || { + t.store(true, Ordering::Relaxed); + Ok(()) + }, + || (), + ); + assert_eq!(r, Err(GPU_CRASHED.to_string())); + assert!(!touched.load(Ordering::Relaxed) && marker.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_set_up_that_takes_too_long_is_given_up_on() { + let dir = folder("slowsetup"); + let marker = dir.join(GPU_SETUP_MARKER); + let started = web_time::Instant::now(); + let slow = || -> Result<(), String> { + std::thread::sleep(Duration::from_millis(600)); + Ok(()) + }; + let r = set_up_card(&marker, Duration::from_millis(50), slow, || ()); + assert!(r.as_ref().is_err_and(|e| e.contains("took longer") && worth_retrying(e)), "{r:?}"); + assert!(started.elapsed() < Duration::from_millis(500), "the caller does not wait for the card"); + // while the first is still stuck, another set-up of the same model does not start a second one + let again = set_up_card(&marker, Duration::from_secs(5), || Ok(()), || ()); + assert!(again.is_err_and(|e| e.contains("still setting up") && worth_retrying(&e))); + // once it ends its marker goes, and the card can be set up again + while marker.exists() { + assert!(started.elapsed() < Duration::from_secs(10), "the stuck set-up ended"); + std::thread::sleep(Duration::from_millis(20)); + } + while SETTING_UP.lock().unwrap().iter().any(|m| m == &marker) { + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(set_up_card(&marker, Duration::from_secs(5), || Ok(1), || 2), Ok((1, 2))); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs new file mode 100644 index 000000000..28ff953bf --- /dev/null +++ b/crates/engine/src/denoise/model.rs @@ -0,0 +1,439 @@ +#[cfg(feature = "denoise")] +use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STUCK, card_is_faster, make_gpu}; +#[cfg(feature = "denoise")] +use super::settings::{installed_models, read_settings}; +#[cfg(feature = "denoise")] +use super::state::Pace; +use super::*; + +/// Stack of the threads that run the model (the runtime recurses through its graph). +pub(super) const STACK: usize = 16 << 20; + +// --------------------------------------------------------------------------------------------------------------------- +// the model + +/// Where the model runs (Settings ▸ AI Denoise ▸ Speed; `denoise.settings {runOn}`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RunOn { + /// The graphics card where it is faster than the processor at the pace the work goes at (both are timed on this + /// computer when the card is set up), else the processor. + #[default] + Auto, + /// The graphics card whenever it can run the model, even where the processor would be faster. + Gpu, + /// Always the processor. + Cpu, +} + +impl RunOn { + /// `auto`, `gpu` or `cpu`. + pub fn parse(name: &str) -> Option { + match name { + "auto" => Some(RunOn::Auto), + "gpu" => Some(RunOn::Gpu), + "cpu" => Some(RunOn::Cpu), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + RunOn::Auto => "auto", + RunOn::Gpu => "gpu", + RunOn::Cpu => "cpu", + } + } +} + +/// A loaded denoise model: what runs tiles, and a self-check. +pub(crate) trait Model: Send + Sync { + /// What runs a photo's tiles, and how many to run at once, when `threads` may be used and the setting is `run_on` + /// (a graphics card is kept busy by a few). The first time the card may be used it is set up and checked here. + fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize); + /// Check it does something sensible: the test's result, or why not. Unless `run_on` is the processor the graphics + /// card is set up and checked too, and the result says where the work will run. + fn self_test(&self, run_on: RunOn) -> Result; + /// Conservative scratch bound for an unknown runner. + fn bytes_per_tile(&self) -> usize { + 512 * 1024 * 1024 + } + /// Where the tiles run: `{kind: "gpu", adapter}`, `{kind: "cpu", reason?}` or `{kind: "pending"}` (the card is not + /// set up yet), with the card's and the processor's time for the check tile (`cardMs`, `cpuMs`) once both were + /// timed. Never starts anything. + fn device(&self, _run_on: RunOn) -> Value { + json!({"kind": "cpu"}) + } +} + +/// Loads the model at a path as its manifest describes it. +pub(crate) type Loader = Arc Result, String> + Send + Sync>; + +/// A set-up that may work another time (a crash, the time limit), unlike one the card or the model cannot do. +#[cfg(feature = "denoise")] +pub(super) fn worth_retrying(why: &str) -> bool { + why == GPU_CRASHED || why == GPU_STUCK || why.starts_with(GPU_SLOW) +} + +/// The model on our pure-Rust CPU runner, and on the graphics card when there is one that can run it: the card is set up for the +/// first photo that may use it, checked against the CPU's answer on a check tile and timed against it, and used while +/// it keeps working; a tile it gets wrong is run on the CPU, so a photo is always finished. +#[cfg(feature = "denoise")] +pub(super) struct Network { + pub(super) cpu: lightcraft_denoise::runtime::CpuRunner, + pub(super) path: PathBuf, + pub(super) tile: usize, + pub(super) gpu: OnceLock>, + /// Tiles the card got wrong (an error, numbers that are not finite) and the CPU ran. + pub(super) fallbacks: AtomicUsize, + /// Tiles at once of the photo started last (0: none yet), to say where the work runs. + pub(super) last_threads: AtomicUsize, + /// Another computer's graphics card (`LIGHTCRAFT_DENOISE_URL` + `LIGHTCRAFT_DENOISE_TOKEN`; see + /// `docs/denoise.md` → Another computer's graphics card), greeted on first use: it takes tiles while it has a free + /// slot, on top of this computer's runner. + pub(super) remote: Option, + /// Whether this computer's share runs on the card (else the CPU), as chosen for the photo started last. + pub(super) local_card: AtomicBool, +} + +#[cfg(feature = "denoise")] +pub(super) struct RemoteSide { + runner: lightcraft_denoise::remote::Remote, + /// The greeting, and the server's time for the check tile there and back (ms; the faster of two runs). + greeted: OnceLock>, + /// Whether the photo started last shares tiles with the server, and if not because it is slower, the two times. + share: AtomicBool, + slower: Mutex>, +} + +#[cfg(feature = "denoise")] +impl RemoteSide { + /// From the environment, for the model whose file has `sha256` and tiles of `tile` cells. + fn from_env(sha256: &str, tile: usize) -> Option { + let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + let address = var("LIGHTCRAFT_DENOISE_URL")?; + let token = var("LIGHTCRAFT_DENOISE_TOKEN").unwrap_or_default(); + Some(RemoteSide::new(lightcraft_denoise::remote::Remote::new(&address, &token, sha256, tile))) + } + + pub(super) fn new(runner: lightcraft_denoise::remote::Remote) -> RemoteSide { + RemoteSide { runner, greeted: OnceLock::new(), share: AtomicBool::new(false), slower: Mutex::new(None) } + } + + /// Greet the server and time a check tile there and back: a server behind a slow link can take seconds a tile, and + /// handing it tiles a fast local card would finish sooner only makes the photo wait for it. + fn greet(&self) -> Result<(lightcraft_denoise::remote::ServerInfo, f64), String> { + let info = self.runner.hello().map_err(|e| e.to_string())?; + let input = lightcraft_denoise::runtime::check_tile(info.tile); + let mut best = f64::INFINITY; + for _ in 0..2 { + let started = web_time::Instant::now(); + match self.runner.try_run(&input) { + Some(Ok(_)) => best = best.min(started.elapsed().as_secs_f64() * 1000.0), + Some(Err(e)) => return Err(format!("the check tile failed: {e}")), + None => return Err("no free slot for the check tile".into()), + } + } + Ok((info, best)) + } + + /// The server when it can take tiles now (greeting it the first time), else why not. + fn usable(&self, start: bool) -> Result<&lightcraft_denoise::remote::Remote, String> { + let greeted = if start { Some(self.greeted.get_or_init(|| self.greet())) } else { self.greeted.get() }; + match greeted { + None => Err("not contacted yet".into()), + Some(Err(why)) => Err(why.clone()), + Some(Ok(_)) if !self.runner.healthy() => { + Err(format!("it failed {} tiles ({})", self.runner.failures(), self.runner.last_error().unwrap_or_default())) + } + Some(Ok(_)) => Ok(&self.runner), + } + } + + /// The server's time for a tile, once greeted. + fn tile_ms(&self) -> Option { + self.greeted.get().and_then(|g| g.as_ref().ok()).map(|g| g.1) + } + + fn describe(&self) -> Value { + match self.greeted.get() { + None => json!({"address": self.runner.address(), "state": "pending"}), + Some(Err(why)) => json!({"address": self.runner.address(), "state": "unavailable", "reason": why}), + Some(Ok((info, ms))) => { + let slower = *self.slower.lock().unwrap_or_else(PoisonError::into_inner); + let state = match () { + _ if !self.runner.healthy() => "given up", + _ if slower.is_some() => "slower", + _ if self.share.load(Ordering::Relaxed) => "used", + _ => "idle", + }; + let mut v = + json!({"address": info.address, "device": info.device, "state": state, "remoteMs": ms, "failures": self.runner.failures()}); + if let (Some((_, local)), Some(o)) = (slower, v.as_object_mut()) { + o.insert("localMs".into(), json!(local)); + } + if let (Some(e), Some(o)) = (self.runner.last_error(), v.as_object_mut()) { + o.insert("lastError".into(), json!(e)); + } + v + } + } + } +} + +#[cfg(feature = "denoise")] +pub(super) struct GpuSide { + pub(super) runner: Box, + pub(super) adapter: String, + /// The check tile on the card, the fastest of a few runs (ms). + pub(super) card_ms: f64, + /// The check tile on the CPU, on one thread (ms). + pub(super) cpu_ms: f64, +} + +#[cfg(feature = "denoise")] +impl Network { + /// The card when it can be used now, else why not. `start` sets it up when that has not been tried yet; without it + /// nothing is started. + fn card(&self, start: bool) -> Result<&GpuSide, String> { + let side = if start { + Some(self.gpu.get_or_init(|| { + let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_gpu(&self.cpu, &self.path, self.tile))) + .unwrap_or_else(|_| Err("the GPU runner gave up".into())); + if let Err(why) = &made { + log::info!("denoise: running on the CPU ({why})"); + } + made + })) + } else { + self.gpu.get() + }; + let g = match side { + None => return Err("the graphics card is not set up yet".into()), + Some(Err(why)) => return Err(why.clone()), + Some(Ok(g)) => g, + }; + if let Some(why) = lightcraft_gpu::nn::broken_reason() { + return Err(format!("the graphics card stopped working ({why})")); + } + if self.fallbacks.load(Ordering::Relaxed) >= GPU_MAX_FALLBACKS { + return Err(format!("{} got {GPU_MAX_FALLBACKS} tiles wrong", g.adapter)); + } + Ok(g) + } + + /// The card, when a photo made `threads` tiles at a time under `run_on` runs on it; else why the CPU does. + fn choose(&self, run_on: RunOn, threads: usize, start: bool) -> Result<&GpuSide, String> { + if run_on == RunOn::Cpu { + return Err("the processor is chosen in Settings".into()); + } + let g = self.card(start)?; + if run_on == RunOn::Auto && !card_is_faster(g.card_ms, g.cpu_ms, threads) { + return Err(format!("it is faster than {} on this computer", g.adapter)); + } + Ok(g) + } + + fn fell_back(&self, why: &str) { + let n = self.fallbacks.fetch_add(1, Ordering::Relaxed) + 1; + if n == 1 { + log::warn!("denoise: a tile came back wrong from the GPU ({why}); the CPU runs it"); + } else if n == GPU_MAX_FALLBACKS { + log::warn!("denoise: the GPU got {n} tiles wrong; the CPU does the rest"); + } + } +} + +/// A remote server's free slot first (when there is one), then the card when this photo runs on it, with the CPU +/// running any tile either gets wrong. Handed out for photos that run on the card or have a server to share. +#[cfg(feature = "denoise")] +impl TileRunner for Network { + fn run(&self, input: &[f32]) -> Result, RunError> { + if let Some(Ok(remote)) = self.remote.as_ref().filter(|r| r.share.load(Ordering::Relaxed)).map(|r| r.usable(false)) + && let Some(Ok(out)) = remote.try_run(input) + { + return Ok(out); + } + if !self.local_card.load(Ordering::Relaxed) { + return self.cpu.run(input); + } + if let Ok(g) = self.card(false) { + match g.runner.run(input) { + Ok(out) if out.iter().all(|v| v.is_finite()) => return Ok(out), + Ok(_) => self.fell_back("numbers that are not finite"), + Err(e) => self.fell_back(&e.to_string()), + } + } + self.cpu.run(input) + } +} + +#[cfg(feature = "denoise")] +impl Model for Network { + fn bytes_per_tile(&self) -> usize { + self.cpu.bytes_per_tile() + } + fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) { + let threads = threads.max(1); + self.last_threads.store(threads, Ordering::Relaxed); + let (card, local) = match self.choose(run_on, threads, true) { + Ok(_) => (true, threads.min(GPU_PARALLEL)), + Err(_) => (false, threads), + }; + self.local_card.store(card, Ordering::Relaxed); + if let Some(r) = &self.remote { + // this computer's time for a tile: the card's, or the CPU's with `threads` tiles sharing it (as + // `card_is_faster` counts them); unknown when the card was never set up (then the server is used) + let local_ms = match self.gpu.get() { + Some(Ok(g)) if card => g.card_ms, + Some(Ok(g)) => g.cpu_ms / (1.0 + 0.5 * threads.saturating_sub(1) as f64), + _ => f64::INFINITY, + }; + let share = match (r.usable(true), r.tile_ms()) { + (Ok(_), Some(ms)) if ms < local_ms => { + *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = None; + true + } + (Ok(_), Some(ms)) => { + *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = Some((ms, local_ms)); + false + } + _ => false, + }; + r.share.store(share, Ordering::Relaxed); + if let (true, Ok(remote)) = (share, r.usable(false)) { + // the server's slots come on top of this computer's own tiles at once + return (self, local + remote.slots()); + } + } + if card { (self, local) } else { (&self.cpu, local) } + } + + fn self_test(&self, run_on: RunOn) -> Result { + let t = self.cpu.self_test(); + if !t.ok { + let failed: Vec<&str> = t.checks.iter().filter(|(_, ok)| !*ok).map(|(c, _)| c.as_str()).collect(); + return Err(format!("the model failed its self-test: it does not {}", failed.join(", and does not "))); + } + let mut v = serde_json::to_value(&t).map_err(|e| e.to_string())?; + // the card is set up and checked here, so the test says where the work will run + if run_on != RunOn::Cpu { + let _ = self.card(true); + } + if let Some(o) = v.as_object_mut() { + o.insert("device".into(), self.device(run_on)); + } + Ok(v) + } + + fn device(&self, run_on: RunOn) -> Value { + if run_on != RunOn::Cpu && self.gpu.get().is_none() { + return json!({"kind": "pending"}); + } + let threads = match self.last_threads.load(Ordering::Relaxed) { + 0 => Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None), + n => n, + }; + let mut v = match self.choose(run_on, threads, false) { + Ok(g) => json!({"kind": "gpu", "adapter": g.adapter}), + // a set-up that crashed or ran out of time can be tried again (`denoise.settings {runOn}`) + Err(why) => json!({"kind": "cpu", "retry": worth_retrying(&why), "reason": why}), + }; + if let (Some(Ok(g)), Some(o)) = (self.gpu.get(), v.as_object_mut()) { + // measured on this computer, on the check tile: the card's best run, and one tile alone on the CPU + o.insert("cardMs".into(), json!(g.card_ms)); + o.insert("cpuMs".into(), json!(g.cpu_ms)); + o.insert("fellBack".into(), json!(self.fallbacks.load(Ordering::Relaxed))); + } + if let (Some(r), Some(o)) = (&self.remote, v.as_object_mut()) { + o.insert("remote".into(), r.describe()); + } + v + } +} + +/// The loader of this build: our pure-Rust executor with the `denoise` feature, else one that says why nothing can run. +pub(crate) fn default_loader() -> Loader { + #[cfg(feature = "denoise")] + { + Arc::new(|path, manifest| { + // the server must run this very file: identified by the manifest's hash, else the file's + let sha256 = match &manifest.sha256 { + Some(h) => Some(h.clone()), + None if std::env::var_os("LIGHTCRAFT_DENOISE_URL").is_some() => lightcraft_denoise::hash::sha256_file(path).ok(), + None => None, + }; + let remote = sha256.and_then(|h| RemoteSide::from_env(&h, manifest.tile as usize)); + Ok(Arc::new(network(path, manifest, remote)?) as Arc) + }) + } + #[cfg(not(feature = "denoise"))] + { + Arc::new(|_, _| Err("this build cannot run denoise models".to_string())) + } +} + +/// The model at `path` on this computer, sharing tiles with `remote` when there is one. +#[cfg(feature = "denoise")] +pub(super) fn network(path: &Path, manifest: &DenoiserManifest, remote: Option) -> Result { + let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?; + Ok(Network { + remote, + local_card: AtomicBool::new(true), + cpu, + path: path.to_path_buf(), + tile: manifest.tile as usize, + gpu: OnceLock::new(), + fallbacks: AtomicUsize::new(0), + last_threads: AtomicUsize::new(0), + }) +} + +/// A model ready to be served to other computers (`lightcraft-cli denoise-serve`). +#[cfg(feature = "denoise")] +pub struct Serving { + pub model: lightcraft_denoise::remote::ModelId, + pub tile: usize, + /// Where its tiles run here: the graphics card's name, or why the CPU does. + pub device: String, + pub run: lightcraft_denoise::remote::RunTile, +} + +/// The installed model `id` (default: the one selected in Settings) in the models folder `dir` (default: this user's), +/// set up to run on `run_on` as the app would: the card checked against the CPU first, the CPU when the card can't. +#[cfg(feature = "denoise")] +pub fn serving(dir: Option<&Path>, id: Option<&str>, run_on: RunOn) -> Result { + let dir = match dir { + Some(d) => d.to_path_buf(), + None => crate::config::default_denoise_models_dir().ok_or("no denoise models folder (set LIGHTCRAFT_DENOISE_MODELS)")?, + }; + let want = match id { + Some(i) => i.to_string(), + None => read_settings(&dir).model.ok_or_else(|| { + format!("no denoise model is selected in {} (install one in Settings ▸ AI Denoise, or name it with --model)", dir.display()) + })?, + }; + let installed = installed_models(&dir) + .into_iter() + .find(|m| m.manifest.id == want) + .ok_or_else(|| format!("no installed denoise model `{want}` in {}", dir.display()))?; + let m = &installed.manifest; + // the same identity a client derives (see `default_loader`) + let sha256 = match &m.sha256 { + Some(h) => h.clone(), + None => lightcraft_denoise::hash::sha256_file(&installed.onnx).map_err(|e| format!("{}: {e}", installed.onnx.display()))?, + }; + let net = Arc::new(network(&installed.onnx, m, None)?); + let tile = m.tile as usize; + // set the card up now (and say where the tiles run) rather than with the first client's tile + let _ = net.runner(run_on, GPU_PARALLEL); + let d = net.device(run_on); + let device = match d["kind"].as_str() { + Some("gpu") => format!("GPU: {}", d["adapter"].as_str().unwrap_or("?")), + _ => format!("CPU ({})", d["reason"].as_str().unwrap_or("chosen")), + }; + let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| net.runner(run_on, GPU_PARALLEL).0.run(input)); + Ok(Serving { model: lightcraft_denoise::remote::ModelId { id: m.id.clone(), version: m.version.clone(), sha256 }, tile, device, run }) +} + +/// A model that is loaded on first use and kept, shared by the background job and the exports. +pub(crate) type ModelSlot = Arc>>>; diff --git a/crates/engine/src/denoise/products.rs b/crates/engine/src/denoise/products.rs new file mode 100644 index 000000000..499e8fd6e --- /dev/null +++ b/crates/engine/src/denoise/products.rs @@ -0,0 +1,234 @@ +use super::model::{Loader, Model, ModelSlot, RunOn, STACK}; +use super::settings::EXT; +use super::*; + +/// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them. +const CLIP: f32 = 0.99; + +/// The model in use. +pub(crate) struct Active { + pub id: String, + pub manifest: DenoiserManifest, + pub onnx: PathBuf, + pub fingerprint: String, + pub model: ModelSlot, +} + +// --------------------------------------------------------------------------------------------------------------------- +// products + +/// The photos whose files can be denoised at all: raw files developed from their sensor data and read from disk. +pub(crate) fn eligible(p: &Photo) -> bool { + p.develops_raw() && matches!(p.source, Source::File { .. }) && !p.deleted +} + +/// A product's name: what it was made from and with. +pub(crate) fn product_key(fingerprint: &str, p: &Photo) -> String { + let mut h = Hasher128::new(); + h.str("lcdn").u64(u64::from(product::ALGORITHM)).str(fingerprint).str(&crate::media::content_key(p)); + h.finish().to_string() +} + +pub(crate) fn product_path(dir: &Path, key: &str) -> PathBuf { + dir.join(format!("{key}.{EXT}")) +} + +/// The `.lcdn` files in `dir`: path, size, last change. +pub(super) fn products_in(dir: &Path) -> Vec<(PathBuf, u64, SystemTime)> { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + entries + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == EXT)) + .filter_map(|e| { + let m = e.metadata().ok()?; + m.is_file().then(|| (e.path(), m.len(), m.modified().unwrap_or(SystemTime::UNIX_EPOCH))) + }) + .collect() +} + +/// Make sure only one thread writes a product at a time: a second one waits, then finds it made. +fn making() -> &'static (Mutex>, Condvar) { + static MAKING: OnceLock<(Mutex>, Condvar)> = OnceLock::new(); + MAKING.get_or_init(|| (Mutex::new(HashSet::new()), Condvar::new())) +} + +struct Slot(PathBuf); + +impl Slot { + fn enter(path: &Path) -> Slot { + let (set, cv) = making(); + let mut g = set.lock().unwrap_or_else(PoisonError::into_inner); + while g.contains(path) { + g = cv.wait(g).unwrap_or_else(PoisonError::into_inner); + } + g.insert(path.to_path_buf()); + Slot(path.to_path_buf()) + } +} + +impl Drop for Slot { + fn drop(&mut self) { + let (set, cv) = making(); + set.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.0); + cv.notify_all(); + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// making a product + +/// Everything a thread needs to make one product, detached from the session. +#[derive(Clone)] +pub(crate) struct JobSpec { + /// The original file. + pub path: String, + pub read: Option, + pub product: PathBuf, + pub key: String, + pub manifest: DenoiserManifest, + pub onnx: PathBuf, + pub model: ModelSlot, + pub loader: Loader, + /// Tiles run at once. + pub parallel: usize, + /// Where the model runs (the setting). + pub run_on: RunOn, +} + +/// Why a product was not made. +#[derive(Debug, PartialEq)] +pub(crate) enum MakeError { + /// Denoise does not apply to this file (not a Bayer raw): nothing is wrong. + Unsupported(String), + Failed(String), + Cancelled, +} + +impl From for MakeError { + fn from(e: RunError) -> Self { + match e { + RunError::Cancelled => MakeError::Cancelled, + other => MakeError::Failed(other.to_string()), + } + } +} + +/// Progress and cancel of a running job. +#[derive(Default)] +pub(crate) struct Progress { + pub(super) done: AtomicUsize, + pub(super) total: AtomicUsize, + cancel: AtomicBool, +} + +impl Progress { + /// Ask the job to stop at the next tile. + pub(crate) fn cancel_now(&self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The loaded model, loading it first when it is not (kept for the next photo). +pub(super) fn loaded(spec: &JobSpec) -> Result, MakeError> { + if let Some(m) = spec.model.lock().unwrap_or_else(PoisonError::into_inner).clone() { + return Ok(m); + } + let m = (spec.loader)(&spec.onnx, &spec.manifest).map_err(|e| MakeError::Failed(format!("the denoise model could not be loaded: {e}")))?; + *spec.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(m.clone()); + Ok(m) +} + +/// The cell `[(0,0), (1,0), (0,1), (1,1)]` colours of a Bayer mosaic and so its layout. +fn layout_of(cfa: &lightcraft_raw::Cfa) -> Option { + Layout::from_cell([cfa.color_at(0, 0), cfa.color_at(1, 0), cfa.color_at(0, 1), cfa.color_at(1, 1)]) +} + +/// Make the product of `spec` unless it is there: decode the photo, run the model over its mosaic, write the result. +/// `Ok(true)` when it was made now, `Ok(false)` when it already existed. +pub(crate) fn make_product(spec: &JobSpec, progress: Option<&Progress>) -> Result { + let _slot = Slot::enter(&spec.product); + if product::is_current(&spec.product, &spec.key) { + return Ok(false); + } + let began = web_time::Instant::now(); + let cancelled = || progress.is_some_and(|p| p.cancel.load(Ordering::Relaxed)); + if cancelled() { + return Err(MakeError::Cancelled); + } + let bytes = match &spec.read { + Some(r) => r(&spec.path).map_err(MakeError::Failed)?, + None => std::fs::read(&spec.path).map_err(|e| MakeError::Failed(format!("{}: {e}", spec.path)))?, + }; + // the decode and the normalised mosaic wait their turn at the process-wide memory gate, as every decode does + let (mosaic, layout) = { + let _held = crate::memory::work_gate().acquire(bytes.len().saturating_mul(4)); + if lightcraft_raw::probe(&bytes).is_none() { + return Err(MakeError::Unsupported("not a raw file".into())); + } + let raw = match lightcraft_raw::decode(&bytes) { + Ok(r) => r, + Err(lightcraft_raw::RawError::Unsupported(why)) => return Err(MakeError::Unsupported(why)), + Err(e) => return Err(MakeError::Failed(e.to_string())), + }; + drop(bytes); + if raw.cpp != 1 || !raw.cfa.as_ref().is_some_and(lightcraft_raw::Cfa::is_bayer) { + return Err(MakeError::Unsupported( + "only Bayer raw files can be denoised (this one has another sensor layout or is already demosaiced)".into(), + )); + } + let n = raw.normalized().map_err(|e| MakeError::Failed(e.to_string()))?; + drop(raw); + let layout = + n.cfa.as_ref().and_then(layout_of).ok_or_else(|| MakeError::Unsupported("the colour filter layout is not a Bayer pattern".into()))?; + (n, layout) + }; + if cancelled() { + return Err(MakeError::Cancelled); + } + let decoded = began.elapsed(); + let model = loaded(spec)?; + // Packed input, returned RGB and the preceding group's RGB overlap while tiles run. + let scratch = + model.bytes_per_tile().saturating_add(128usize.saturating_mul(spec.manifest.tile as usize).saturating_mul(spec.manifest.tile as usize)); + let (_, budget) = crate::memory::work_gate().usage(); + let parallel = spec.parallel.clamp(1, 4).min((budget / scratch.max(1)).max(1)); + let _held = crate::memory::work_gate().acquire(mosaic.data.len().saturating_mul(20).saturating_add(scratch.saturating_mul(parallel))); + let (runner, parallel) = model.runner(spec.run_on, parallel); + let params = Params { + tile: spec.manifest.tile as usize, + overlap: spec.manifest.overlap as usize, + gain: spec.manifest.gain, + clip: Some(CLIP), + parallel: parallel.max(1), + }; + let report = |done: usize, total: usize| { + if let Some(p) = progress { + p.total.store(total, Ordering::Relaxed); + p.done.store(done, Ordering::Relaxed); + } + }; + let ctl = Control { cancel: progress.map(|p| &p.cancel), progress: Some(&report) }; + // the tiles run on threads of their own, so a busy window's renders keep theirs + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(params.parallel) + .stack_size(STACK) + .thread_name(|i| format!("denoise-{i}")) + .build() + .map_err(|e| MakeError::Failed(format!("could not start the worker threads: {e}")))?; + let rgb = pool.install(|| denoise_bayer(&mosaic.data, mosaic.width, mosaic.height, layout, runner, ¶ms, &ctl))?; + drop(mosaic); + if cancelled() { + return Err(MakeError::Cancelled); + } + let ran = began.elapsed(); + product::write(&spec.product, &rgb, &spec.key).map_err(|e| MakeError::Failed(format!("could not save the denoised picture: {e}")))?; + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!( + "[denoise] read + decode {:.0} ms, model (set-up, tiles, blend) {:.0} ms, write {:.0} ms", + decoded.as_secs_f64() * 1e3, + (ran - decoded).as_secs_f64() * 1e3, + (began.elapsed() - ran).as_secs_f64() * 1e3 + ); + } + Ok(true) +} diff --git a/crates/engine/src/denoise/session.rs b/crates/engine/src/denoise/session.rs new file mode 100644 index 000000000..6a452f693 --- /dev/null +++ b/crates/engine/src/denoise/session.rs @@ -0,0 +1,509 @@ +#[cfg(doc)] +use super::model::Model; +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::*; + +impl Session { + /// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded. + pub(crate) fn denoise_device(&self) -> Value { + let loaded = self.denoise.active.as_ref().and_then(|a| a.model.lock().unwrap_or_else(PoisonError::into_inner).clone()); + match loaded { + Some(m) => m.device(self.denoise.settings.run_on()), + None => json!({"kind": "none"}), + } + } + + /// The folder the products of the open library are kept in; `None` without a library on disk. + pub(crate) fn denoise_products_dir(&self) -> Option { + self.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(DIR)) + } + + /// Read which model is in use (at most every couple of seconds): the manifest and file of the model the settings + /// name, if it is installed. + pub(crate) fn denoise_refresh_active(&mut self, force: bool) { + let fresh = self.denoise.active_seen.is_some_and(|t| t.elapsed() < SETTINGS_TTL); + if fresh && !force { + return; + } + self.denoise.active_seen = Some(web_time::Instant::now()); + self.denoise.settings = self.denoise.models_dir.as_deref().map(read_settings).unwrap_or_default(); + let chosen = self.denoise.models_dir.as_deref().and_then(|dir| { + let id = self.denoise.settings.model.clone()?; + installed_models(dir).into_iter().find(|i| i.manifest.id == id) + }); + let found = chosen.map(|i| (i.fingerprint(), i)); + let same = match (&self.denoise.active, &found) { + (None, None) => true, + (Some(a), Some((fp, _))) => a.fingerprint == *fp, + _ => false, + }; + if same { + return; + } + // another model: what was made with the old one is not this one's, and what failed may work now + self.denoise.active = found.map(|(fingerprint, i)| Active { + id: i.manifest.id.clone(), + manifest: i.manifest, + onnx: i.onnx, + fingerprint, + model: Arc::new(Mutex::new(None)), + }); + if let Some(r) = self.denoise.running.take() { + r.progress.cancel_now(); + } + self.denoise.queue.clear(); + self.denoise.failed.clear(); + self.denoise.dirty = true; + } + + fn denoise_spec(&self, p: &Photo) -> Option<(DenoiseSpec, String)> { + let (dir, active) = (self.denoise_products_dir()?, self.denoise.active.as_ref()?); + let key = product_key(&active.fingerprint, p); + Some((DenoiseSpec { product: product_path(&dir, &key), key: key.clone(), make: None }, key)) + } + + /// Make the index of ready photos from the products on disk (a directory listing and a hash per photo: no file is + /// opened). Pictures that appeared or vanished drop the decoded sources they affect. The photo being made is its + /// job's to report (see [`Self::denoise_check_photo`]). + pub(crate) fn denoise_reindex(&mut self) { + self.denoise.dirty = false; + self.denoise.reindexed = Some((self.catalog.revision, web_time::Instant::now())); + let running = self.denoise.running.as_ref().map(|r| r.photo); + let mut now: HashMap = HashMap::new(); + if let (Some(dir), Some(active)) = (self.denoise_products_dir(), self.denoise.active.as_ref()) { + let names: HashSet = + products_in(&dir).into_iter().filter_map(|(p, _, _)| p.file_stem().and_then(|s| s.to_str()).map(str::to_string)).collect(); + if !names.is_empty() { + for p in self.catalog.photos().filter(|p| eligible(p) && running != Some(p.id)) { + let key = product_key(&active.fingerprint, p); + if names.contains(&key) { + now.insert(p.id, DenoiseSpec { product: product_path(&dir, &key), key, make: None }); + } + } + } + } + let mut changed = false; + for id in self.media.denoise.ids() { + if !now.contains_key(&id) { + self.media.denoise.remove(id); + self.media.forget(id); + changed = true; + } + } + for (id, spec) in now { + if self.media.denoise.spec(id).is_none_or(|old| old.key != spec.key) { + self.media.denoise.set(id, spec); + self.media.forget(id); + changed = true; + } + } + if changed { + self.denoise.generation += 1; + } + } + + /// A photo whose file changed (its content key is not the one its index entry was made for) has no picture. + /// + /// The photo whose picture is being made is left to its job: the worker saves the picture before it reports, so the + /// file is on disk while the job still counts as running. Finding it here would make the photo Ready while the session + /// is busy with it (#719); [`Self::denoise_poll`] makes it Ready in the pump that takes the job's result instead. + fn denoise_check_photo(&mut self, id: PhotoId) { + if self.denoise.running.as_ref().is_some_and(|r| r.photo == id) { + return; + } + let Some(p) = self.catalog.photo(id).cloned() else { return }; + let Some((spec, _)) = self.denoise_spec(&p) else { return }; + match self.media.denoise.spec(id) { + Some(have) if have.key != spec.key => { + self.media.denoise.remove(id); + self.media.forget(id); + self.denoise.generation += 1; + } + None if spec.product.is_file() && product::is_current(&spec.product, &spec.key) => { + // made by an export, or by another session + self.media.denoise.set(id, spec); + self.media.forget(id); + self.denoise.generation += 1; + } + _ => {} + } + } + + /// How photo `id` stands. + pub fn denoise_photo_state(&self, id: PhotoId) -> PhotoState { + let Some(p) = self.catalog.photo(id) else { return PhotoState::NotApplicable }; + if !eligible(p) { + return PhotoState::NotApplicable; + } + if self.denoise.active.is_none() { + // the model folder has not been looked at yet (just started): not "no model" until it has been + return if self.denoise.active_seen.is_none() { PhotoState::Idle } else { PhotoState::NoModel }; + } + if self.denoise_products_dir().is_none() { + return PhotoState::Failed { + why: "AI Denoise keeps its pictures in the library folder, and this session has none.".into(), + unsupported: true, + }; + } + if self.media.denoise.spec(id).is_some() { + return PhotoState::Ready; + } + if let Some(r) = self.denoise.running.as_ref().filter(|r| r.photo == id) { + return PhotoState::Running { done: r.progress.done.load(Ordering::Relaxed), total: r.progress.total.load(Ordering::Relaxed) }; + } + if let Some(ahead) = self.denoise.queue.iter().position(|q| *q == id) { + return PhotoState::Queued { ahead: ahead + usize::from(self.denoise.running.is_some()) }; + } + let key = self.denoise_spec(p).map(|s| s.1); + match self.denoise.failed.get(&id) { + Some(f) if Some(&f.key) == key.as_ref() => PhotoState::Failed { why: f.why.clone(), unsupported: f.unsupported }, + _ => PhotoState::Idle, + } + } + + /// Ask for photos' pictures to be made (the ones that are not made, queued or known to be impossible). With `retry` + /// a photo that failed before is tried again. A photo that is not raw is skipped. Returns how many were queued. + pub(crate) fn denoise_enqueue(&mut self, ids: &[PhotoId], front: bool, retry: bool) -> usize { + self.denoise_refresh_active(false); + if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { + return 0; + } + let mut added = 0; + for id in ids { + let Some(p) = self.catalog.photo(*id).cloned() else { continue }; + if !eligible(&p) || self.media.denoise.spec(*id).is_some() || self.denoise.queue.contains(id) { + continue; + } + if self.denoise.running.as_ref().is_some_and(|r| r.photo == *id) || self.denoise.queue.len() >= MAX_QUEUE { + continue; + } + let Some((_, key)) = self.denoise_spec(&p) else { continue }; + if retry { + self.denoise.failed.remove(id); + } else if self.denoise.failed.get(id).is_some_and(|f| f.key == key) { + continue; + } + if front { + self.denoise.queue.push_front(*id); + } else { + self.denoise.queue.push_back(*id); + } + added += 1; + } + added + } + + /// Stop the job that is running, and with `all` forget everything that waits. + pub(crate) fn denoise_cancel(&mut self, photo: Option, all: bool) -> usize { + let mut n = 0; + if let Some(r) = &self.denoise.running + && photo.is_none_or(|p| p == r.photo) + { + r.progress.cancel_now(); + n += 1; + } + let before = self.denoise.queue.len(); + match photo { + Some(p) => self.denoise.queue.retain(|q| *q != p), + None if all => self.denoise.queue.clear(), + None => {} + } + n + before - self.denoise.queue.len() + } + + /// The photos the automatic queue looks at: the one open and the ones selected, those with a Denoise amount. + fn denoise_wanted(&self) -> Vec { + let Selection { active, ids, .. } = &self.selection; + active + .iter() + .chain(ids.iter().take(AUTO_LOOK)) + .copied() + .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) + .collect() + } + + fn denoise_job_spec(&self, p: &Photo, parallel: usize) -> Option { + let Source::File { path } = &p.source else { return None }; + let (spec, _) = self.denoise_spec(p)?; + let active = self.denoise.active.as_ref()?; + Some(JobSpec { + path: path.clone(), + read: self.media.file_bytes.clone(), + product: spec.product, + key: spec.key, + manifest: active.manifest.clone(), + onnx: active.onnx.clone(), + model: active.model.clone(), + loader: self.denoise.loader.clone(), + parallel, + run_on: self.denoise.settings.run_on(), + }) + } + + /// Start the next queued photo on its own thread. + fn denoise_start_next(&mut self, parallel: usize) { + while let Some(id) = self.denoise.queue.pop_front() { + let Some(p) = self.catalog.photo(id).cloned() else { continue }; + if !eligible(&p) || self.media.denoise.spec(id).is_some() { + continue; + } + let Some(spec) = self.denoise_job_spec(&p, parallel) else { continue }; + // another session or an export may have made it since it was queued + if product::is_current(&spec.product, &spec.key) { + self.denoise_check_photo(id); + continue; + } + let progress = Arc::new(Progress::default()); + let outcome: Arc>> = Arc::new(Mutex::new(None)); + let (key, product) = (spec.key.clone(), spec.product.clone()); + let (worker_progress, worker_outcome) = (progress.clone(), outcome.clone()); + let started = std::thread::Builder::new().name("denoise".into()).stack_size(STACK).spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_product(&spec, Some(&worker_progress)))); + let o = match result { + Ok(Ok(_)) => Outcome::Done, + Ok(Err(MakeError::Cancelled)) => Outcome::Cancelled, + Ok(Err(MakeError::Unsupported(why))) => Outcome::Failed(why, true), + Ok(Err(MakeError::Failed(why))) => Outcome::Failed(why, false), + Err(_) => Outcome::Failed("the denoiser stopped unexpectedly".into(), false), + }; + *worker_outcome.lock().unwrap_or_else(PoisonError::into_inner) = Some(o); + }); + match started { + Ok(_) => { + self.denoise.running = Some(Running { photo: id, key, product, progress, outcome, started: web_time::Instant::now() }); + } + Err(e) => { + self.denoise.failed.insert(id, Failure { key, why: format!("could not start the denoiser: {e}"), unsupported: false }); + continue; + } + } + return; + } + } + + /// Take in the running job's result. + fn denoise_poll(&mut self) { + let Some(r) = &self.denoise.running else { return }; + let Some(outcome) = r.outcome.lock().unwrap_or_else(PoisonError::into_inner).take() else { return }; + let Some(r) = self.denoise.running.take() else { return }; + match outcome { + Outcome::Done => { + self.denoise.made += 1; + self.denoise_check_photo(r.photo); + self.denoise_evict(Some(&r.product)); + } + Outcome::Failed(why, unsupported) => { + self.denoise.failed.insert(r.photo, Failure { key: r.key, why, unsupported }); + } + Outcome::Cancelled => {} + } + // the picture's state changed: the interface draws it again + self.denoise.generation += 1; + } + + /// Keep the cache within its limit: the pictures nobody asks for go first, the oldest first; `keep` (the one just + /// made) never goes. The index forgets what was deleted. + pub(crate) fn denoise_evict(&mut self, keep: Option<&Path>) { + let Some(models) = self.denoise.models_dir.clone() else { return }; + self.denoise_evict_to(read_settings(&models).cache_bytes(), keep); + } + + /// [`Self::denoise_evict`] for a limit of `limit` bytes. + pub(crate) fn denoise_evict_to(&mut self, limit: u64, keep: Option<&Path>) { + let Some(dir) = self.denoise_products_dir() else { return }; + let mut files = products_in(&dir); + let mut total: u64 = files.iter().map(|f| f.1).sum(); + if total <= limit { + return; + } + // the pictures photos use now (a Denoise amount above 0) are the last to go + let wanted: HashSet = self + .media + .denoise + .ids() + .into_iter() + .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) + .filter_map(|id| self.media.denoise.spec(id).map(|s| s.product.clone())) + .collect(); + files.sort_by_key(|(path, _, modified)| (wanted.contains(path), *modified)); + let mut gone: HashSet = HashSet::new(); + for (path, size, _) in files { + if total <= limit { + break; + } + if keep == Some(path.as_path()) { + continue; + } + if std::fs::remove_file(&path).is_ok() { + total = total.saturating_sub(size); + gone.insert(path); + } + } + if gone.is_empty() { + return; + } + for id in self.media.denoise.ids() { + if self.media.denoise.spec(id).is_some_and(|s| gone.contains(&s.product)) { + self.media.denoise.remove(id); + self.media.forget(id); + } + } + self.denoise.generation += 1; + } + + /// Delete every product of this library (they are made again when wanted). + pub(crate) fn denoise_clear(&mut self) -> (usize, u64) { + self.denoise_cancel(None, true); + let (mut files, mut bytes) = (0, 0); + if let Some(dir) = self.denoise_products_dir() { + for (path, size, _) in products_in(&dir) { + if std::fs::remove_file(&path).is_ok() { + files += 1; + bytes += size; + } + } + } + self.denoise.failed.clear(); + self.denoise.touch(); + (files, bytes) + } + + /// Per frame: take in what finished, look at what the user is looking at, and keep one job going. Cheap when + /// nothing is happening (no file is opened unless a photo with a Denoise amount lacks its picture). + pub(crate) fn denoise_pump(&mut self, pace: Pace) { + crate::cmd::denoise::finish_downloads(self); + self.denoise_refresh_active(false); + // photos that changed (a replaced file has another key) must not keep the picture of what they were + let stale = !self.media.denoise.is_empty() + && self.denoise.reindexed.is_none_or(|(rev, at)| rev != self.catalog.revision && at.elapsed() > SETTINGS_TTL); + if self.denoise.dirty || stale { + self.denoise_reindex(); + } + self.denoise_poll(); + if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { + return; + } + let (cap, auto) = (self.denoise.settings.threads, self.denoise.settings.auto()); + let wanted = self.denoise_wanted(); + for id in &wanted { + self.denoise_check_photo(*id); + } + if auto { + // the photo being edited goes first + let (open, others): (Vec, Vec) = wanted.into_iter().partition(|id| self.selection.active == Some(*id)); + self.denoise_enqueue(&others, false, false); + self.denoise_enqueue(&open, true, false); + } + if self.denoise.running.is_none() && pace != Pace::Pause { + let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); + self.denoise_start_next(pace.parallel(cores, cap)); + } + } + + /// The statistics `denoise.status` reports besides the queue (reads the cache folder). + pub(crate) fn denoise_cache_usage(&self) -> (usize, u64) { + match self.denoise_products_dir() { + Some(dir) => { + let files = products_in(&dir); + (files.len(), files.iter().map(|f| f.1).sum()) + } + None => (0, 0), + } + } + + /// Are pictures made without being asked for photos being looked at (Settings ▸ AI Denoise)? + pub fn denoise_auto(&self) -> bool { + self.denoise.settings.auto() + } + + /// Is a picture being made, or waiting for its turn with a model to make it? (Headless runs wait for this.) + pub fn denoise_busy(&self) -> bool { + self.denoise.running.is_some() || (!self.denoise.queue.is_empty() && self.denoise.active.is_some()) + } + + /// Keep AI Denoise's row in the activity stack up to date (each `denoise.pump`): there while a photo is being made or + /// waits its turn, with the running photo's tiles and its file name; gone when the queue is idle. It has no ✕: what is + /// made follows the photos' Denoise amounts and Settings ▸ AI Denoise. + pub(crate) fn denoise_track(&mut self) { + if !self.denoise_busy() { + self.denoise.task = None; + return; + } + let task = self.denoise.task.get_or_insert_with(|| { + let t = self.activity.start("denoise", "AI Denoise", crate::activity::Cancel::No); + t.set_unit(crate::activity::Unit::Percent); + t + }); + match &self.denoise.running { + Some(r) => { + task.progress(r.progress.done.load(Ordering::Relaxed) as u64, r.progress.total.load(Ordering::Relaxed) as u64); + task.detail(self.catalog.photo(r.photo).map_or("", |p| p.file_name.as_str())); + } + None => { + task.progress(0, 0); + task.detail(""); + } + } + } + + /// Seconds the running job has been going, and its progress. + pub(crate) fn denoise_running_json(&self) -> Value { + match &self.denoise.running { + Some(r) => json!({ + "photo": r.photo.0, + "done": r.progress.done.load(Ordering::Relaxed), + "total": r.progress.total.load(Ordering::Relaxed), + "seconds": r.started.elapsed().as_secs_f64(), + }), + None => Value::Null, + } + } + + pub(crate) fn denoise_made(&self) -> u64 { + self.denoise.made + } + + /// The running job's product path and key (tests stand in for the worker's save). + #[cfg(test)] + pub(crate) fn denoise_running_product(&self) -> Option<(PathBuf, String)> { + self.denoise.running.as_ref().map(|r| (r.product.clone(), r.key.clone())) + } + + /// What an export of photo `id` with `settings` needs for its Denoise amount: its denoised picture, made now when the + /// queue has not got to it. `Ok(None)` when denoise does not apply (amount 0, not a Bayer raw), `Err` when it should + /// have and cannot (no model chosen, nowhere to keep the picture, the model failing). + pub(crate) fn denoise_for_export(&mut self, id: PhotoId, settings: &DevelopSettings) -> Result, String> { + if settings.denoise_amount() <= 0.0 { + return Ok(None); + } + let Some(p) = self.catalog.photo(id).cloned() else { return Ok(None) }; + let Some(loader) = self.media.denoise.loader.clone() else { return Ok(None) }; + if !eligible(&p) { + return Ok(None); + } + self.denoise_refresh_active(false); + let name = &p.file_name; + if self.denoise.active.is_none() { + return Err(format!("{name}: Denoise is on for this photo but no denoise model is chosen; choose one in Settings, or set Denoise to 0")); + } + if self.denoise_products_dir().is_none() { + return Err(format!("{name}: Denoise needs a library on disk to keep its pictures in")); + } + self.denoise_check_photo(id); + let Some(job) = self.denoise_job_spec(&p, Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None)) else { + return Ok(None); + }; + let label = name.clone(); + let spec = job.clone(); + let make: MakeProduct = Arc::new(move || match make_product(&spec, None) { + Ok(_) => Ok(true), + Err(MakeError::Unsupported(_)) => Ok(false), + Err(MakeError::Failed(why)) => Err(format!("{label}: could not denoise: {why}")), + Err(MakeError::Cancelled) => Err(format!("{label}: denoise was cancelled")), + }); + Ok(Some((DenoiseSpec { product: job.product, key: job.key, make: Some(make) }, loader))) + } +} diff --git a/crates/engine/src/denoise/settings.rs b/crates/engine/src/denoise/settings.rs new file mode 100644 index 000000000..f8acd5320 --- /dev/null +++ b/crates/engine/src/denoise/settings.rs @@ -0,0 +1,135 @@ +use super::gpu::GPU_SETUP_MARKER; +use super::model::RunOn; +use super::*; + +/// Extension of a product file. +pub const EXT: &str = "lcdn"; + +/// Where the products of a library are kept, inside the library's folder (safe to delete, like `thumbs/`). +pub const DIR: &str = "denoise"; + +/// What the cache may take up unless the user says otherwise (GB). A product is about as large as the raw file's +/// decoded samples, compressed: ~100 MB for 24 megapixels. +pub const DEFAULT_CACHE_GB: u32 = 20; + +/// How long a look at the model settings is trusted. +pub(super) const SETTINGS_TTL: Duration = Duration::from_secs(2); + +// --------------------------------------------------------------------------------------------------------------------- +// settings and installed models + +/// `settings.json` in the models folder. +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub(crate) struct Settings { + /// The installed model in use; `None`: denoise is off. + pub model: Option, + /// Photos with a Denoise amount above 0 that are being looked at (active, selected) get their picture made without + /// being asked (`None`: yes). + pub auto: Option, + /// Cache size limit (GB; `None`: [`DEFAULT_CACHE_GB`]). + pub cache_gb: Option, + /// Most tiles run at once (`None`: by how hard the pump says to work). + pub threads: Option, + /// Where the model runs: `auto`, `gpu` or `cpu` (`None` or anything else: `auto`; see [`RunOn`]). + pub run_on: Option, + /// What came before `runOn`: `false` was the processor only. + pub gpu: Option, +} + +impl Settings { + pub fn auto(&self) -> bool { + self.auto.unwrap_or(true) + } + + pub fn run_on(&self) -> RunOn { + match self.run_on.as_deref().and_then(RunOn::parse) { + Some(r) => r, + None if self.gpu == Some(false) => RunOn::Cpu, + None => RunOn::Auto, + } + } + + pub fn cache_bytes(&self) -> u64 { + u64::from(self.cache_gb.unwrap_or(DEFAULT_CACHE_GB).clamp(1, 10_000)) << 30 + } +} + +pub(crate) fn read_capped(path: &Path, max: usize) -> Option> { + use std::io::Read; + let mut buf = Vec::new(); + std::fs::File::open(path).ok()?.take(max as u64 + 1).read_to_end(&mut buf).ok()?; + (buf.len() <= max).then_some(buf) +} + +pub(crate) fn read_settings(dir: &Path) -> Settings { + read_capped(&dir.join("settings.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() +} + +/// Write a file next to its final name and rename it into place. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let part = path.with_extension("part"); + std::fs::write(&part, bytes).map_err(|e| format!("could not write {}: {e}", path.display()))?; + std::fs::rename(&part, path).map_err(|e| { + let _ = std::fs::remove_file(&part); + format!("could not write {}: {e}", path.display()) + }) +} + +pub(crate) fn write_settings(dir: &Path, st: &Settings) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|e| format!("could not create the denoise models folder: {e}"))?; + write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| e.to_string())?) +} + +/// An installed model folder: `/model.onnx`, `denoise-model.json`, `installed.json`. +pub(crate) struct Installed { + pub manifest: DenoiserManifest, + pub onnx: PathBuf, + /// What was recorded when it was installed (terms accepted, hash of the file, self-test). + pub accepted: Value, +} + +impl Installed { + /// Names the model file the cache is made with: a hash of the file as installed, so another file with the same id + /// never finds this one's products. + pub fn fingerprint(&self) -> String { + let sha = self.accepted.get("sha256").and_then(Value::as_str).or(self.manifest.sha256.as_deref()).unwrap_or(""); + let len = std::fs::metadata(&self.onnx).map(|m| m.len()).unwrap_or(0); + format!("{}@{}:{len}", self.manifest.id, sha) + } +} + +/// The models in the folder. A folder that is not a valid model (wrong name, no model file, a manifest that does not +/// validate or does not match its folder) is ignored, never trusted. +pub(crate) fn installed_models(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut out = Vec::new(); + for e in entries.flatten() { + let p = e.path(); + let Some(name) = p.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue }; + let onnx = p.join("model.onnx"); + if !lightcraft_denoise::licence::valid_id(&name) || !p.is_dir() || !onnx.is_file() { + continue; + } + let Some(m) = read_capped(&p.join("denoise-model.json"), lightcraft_denoise::manifest::MAX_MANIFEST_BYTES) + .and_then(|b| lightcraft_denoise::manifest::parse(&b).ok()) + else { + continue; + }; + if m.id != name { + continue; + } + let accepted = read_capped(&p.join("installed.json"), 8192).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null); + out.push(Installed { manifest: m, onnx, accepted }); + } + out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); + out +} + +/// Forget that setting the graphics card up for a model closed LightCraft before (the markers [`set_up_card`] leaves): +/// the user chose where denoise runs, so the card may be tried again. +pub(crate) fn forget_gpu_set_ups(dir: &Path) { + for i in installed_models(dir) { + let _ = std::fs::remove_file(i.onnx.with_file_name(GPU_SETUP_MARKER)); + } +} diff --git a/crates/engine/src/denoise/state.rs b/crates/engine/src/denoise/state.rs new file mode 100644 index 000000000..76a4b6130 --- /dev/null +++ b/crates/engine/src/denoise/state.rs @@ -0,0 +1,179 @@ +use super::model::{Loader, default_loader}; +use super::products::{Active, Progress}; +use super::settings::Settings; +use super::*; + +/// Most photos waiting in the queue. +pub(super) const MAX_QUEUE: usize = 5000; + +/// Most selected photos the automatic queue looks at in one pump. +pub(super) const AUTO_LOOK: usize = 64; + +// --------------------------------------------------------------------------------------------------------------------- +// the session's state + +/// How hard the background work may go (the app picks it from what the user is doing, like the face scan). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pace { + /// Start nothing new. + Pause, + /// Two tiles at once. + Light, + /// Half the machine. + Normal, + /// Most of the machine: the user is watching the progress. + Full, +} + +impl Pace { + /// `pause`, `light`, `normal` or `full`; anything else is `normal`. + pub fn parse(name: Option<&str>) -> Pace { + match name { + Some("pause") => Pace::Pause, + Some("light") => Pace::Light, + Some("full") => Pace::Full, + _ => Pace::Normal, + } + } + + /// Tiles to run at once on a machine with `cores` threads (`cap`: the user's own limit). + pub fn parallel(self, cores: usize, cap: Option) -> usize { + let n = match self { + Pace::Pause | Pace::Light => 2, + Pace::Normal => cores / 2, + Pace::Full => cores * 4 / 5, + } + .clamp(1, 16); + cap.map_or(n, |c| n.min(c.max(1) as usize)) + } +} + +/// How a photo stands, for the interface. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum PhotoState { + /// Not a photo denoise applies to (not a raw, offline, a demo). + NotApplicable, + /// No model is chosen: nothing can be made. + NoModel, + /// Its picture is made: the Amount slider works. + Ready, + Queued { + /// Photos ahead of it (0 = next). + ahead: usize, + }, + Running { + done: usize, + total: usize, + }, + /// Making it failed (`why`), or denoise does not apply to this file (`unsupported`). + Failed { + why: String, + unsupported: bool, + }, + /// Nothing has asked for it yet. + Idle, +} + +#[derive(Clone)] +pub(super) struct Failure { + pub(super) key: String, + pub(super) why: String, + pub(super) unsupported: bool, +} + +pub(super) enum Outcome { + Done, + Failed(String, bool), + Cancelled, +} + +pub(super) struct Running { + pub(super) photo: PhotoId, + pub(super) key: String, + pub(super) product: PathBuf, + pub(super) progress: Arc, + pub(super) outcome: Arc>>, + pub(super) started: web_time::Instant, +} + +pub(crate) struct State { + /// Where the models are kept (`None` where there is no file system). + pub models_dir: Option, + /// Models being downloaded at the user's request. + pub downloads: Downloads, + pub(crate) install: Option, + /// How a model is loaded (tests put another in). + pub loader: Loader, + pub(crate) active: Option, + pub(super) active_seen: Option, + /// `settings.json` as last read (with the model choice). + pub(crate) settings: Settings, + /// The index must be made again (the library, the model or the cache changed). + pub(super) dirty: bool, + /// The catalog revision and time of the last index, to look again when photos changed. + pub(super) reindexed: Option<(u64, web_time::Instant)>, + pub(super) queue: VecDeque, + pub(super) running: Option, + pub(super) failed: HashMap, + /// Counts every change of what is ready, so the interface knows to draw again. + pub generation: u64, + /// Photos made since the session started. + pub(super) made: u64, + /// AI Denoise's row in the activity stack, while a photo is being made or waits its turn. + pub(super) task: Option, +} + +impl Default for State { + fn default() -> Self { + State { + models_dir: None, + downloads: Downloads::default(), + install: None, + loader: default_loader(), + active: None, + active_seen: None, + settings: Settings::default(), + dirty: true, + reindexed: None, + queue: VecDeque::new(), + running: None, + failed: HashMap::new(), + generation: 0, + made: 0, + task: None, + } + } +} + +impl Drop for State { + /// Quitting stops the job instead of leaving it to finish a photo nobody will see. + fn drop(&mut self) { + if let Some(r) = &self.running { + r.progress.cancel_now(); + } + } +} + +impl State { + /// The library was replaced: nothing carries over (photo ids are per library). + pub fn library_changed(&mut self) { + if let Some(r) = self.running.take() { + r.progress.cancel_now(); + } + self.queue.clear(); + self.failed.clear(); + self.dirty = true; + self.generation += 1; + } + + /// The model choice or the cache changed on disk: look again at the next pump. + pub fn touch(&mut self) { + self.active_seen = None; + self.dirty = true; + } + + pub fn queued(&self) -> usize { + self.queue.len() + } +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index b1a5bce0f..8ba08021f 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -11,616 +11,44 @@ pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace}; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ExportFormat { - #[default] - Jpeg, - Png, - Tiff, - /// A flat PSD (8/16-bit, no layers): what an image editor such as PhotoCraft saves back to. - Psd, - Webp, - Avif, - /// JPEG XL: lossy (VarDCT) or lossless, 8/16-bit, HDR as Rec. 2020 PQ. - Jxl, - /// The original file, unchanged, with an XMP sidecar holding the edits. - Original, - /// A DNG of the raw data (raw photos only), the edits embedded as XMP. - Dng, - /// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in. - RenderedDng, -} - -impl ExportFormat { - pub fn parse(s: &str) -> Option { - Some(match s.to_ascii_lowercase().as_str() { - "jpeg" | "jpg" => Self::Jpeg, - "png" => Self::Png, - "tiff" | "tif" => Self::Tiff, - "psd" => Self::Psd, - "webp" => Self::Webp, - "avif" => Self::Avif, - "jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl, - "original" => Self::Original, - "dng" => Self::Dng, - "rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng, - _ => return None, - }) - } - pub fn extension(self) -> &'static str { - match self { - Self::Jpeg => "jpg", - Self::Png => "png", - Self::Tiff => "tif", - Self::Psd => "psd", - Self::Webp => "webp", - Self::Avif => "avif", - Self::Jxl => "jxl", - // the original keeps its own extension (see `ExportOptions::file_name_for`) - Self::Original => "", - Self::Dng | Self::RenderedDng => "dng", - } - } - - /// Formats that render pixels (everything but `Original` and `Dng`). - pub fn is_rendered(self) -> bool { - !matches!(self, Self::Original | Self::Dng) - } -} - -/// How the output size is chosen (see [`Resize`]). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ResizeMode { - /// `value` = long edge in pixels. - #[default] - LongEdge, - /// `value` = short edge in pixels. - ShortEdge, - /// `value` = width in pixels. - Width, - /// `value` = height in pixels. - Height, - /// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤ - /// `height` (Lightroom's "Width & Height"). - WidthHeight, - /// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the - /// larger; Lightroom's "Dimensions"). - Dimensions, - /// `value` = megapixels. - Megapixels, - /// `value` = percent of the full size. - Percent, -} - -/// Output size: a mode, its value(s) and whether smaller photos are left at their own size. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Resize { - pub mode: ResizeMode, - pub value: f32, - /// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`]. - pub height: u32, - /// Never upscale past the photo's own (cropped) size. - pub dont_enlarge: bool, -} - -impl Default for Resize { - fn default() -> Self { - Self { mode: ResizeMode::LongEdge, value: 2048.0, height: 2048, dont_enlarge: true } - } -} - -/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also -/// Lightroom's ceiling. A full-size or smaller export is never held to it. -pub const MAX_ENLARGED_PIXELS: f64 = 512e6; - -impl Resize { - pub fn long_edge(px: u32) -> Self { - Self { value: px as f32, ..Default::default() } - } - - /// Scale factor from a `w × h` (cropped, full-resolution) photo to the output. - pub fn scale(&self, w: f64, h: f64) -> f64 { - let (w, h) = (w.max(1.0), h.max(1.0)); - let (long, short) = (w.max(h), w.min(h)); - let v = (self.value as f64).max(0.0); - let k = match self.mode { - ResizeMode::LongEdge => v / long, - ResizeMode::ShortEdge => v / short, - ResizeMode::Width => v / w, - ResizeMode::Height => v / h, - ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h), - ResizeMode::Dimensions => { - let (a, b) = (v, self.height as f64); - (a.max(b) / long).min(a.min(b) / short) - } - ResizeMode::Megapixels => (v * 1e6 / (w * h)).sqrt(), - ResizeMode::Percent => v / 100.0, - }; - let k = if self.dont_enlarge { k.min(1.0) } else { k }; - // an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000 - // with Don't Enlarge off asked for gigapixels, more memory than a computer has - let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0)); - // at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more - // than 65535 times as long as it is short (where `clamp` would panic: min above max) - k.max(1.0 / short).min(65_535.0 / long) - } - - /// Output size for a `w × h` (cropped, full-resolution) photo. - pub fn apply(&self, w: f64, h: f64) -> (usize, usize) { - let k = self.scale(w, h); - (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) - } -} - -/// What happens when an output file already exists. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Conflict { - /// Add `-2`, `-3`, … to the name. - #[default] - Unique, - Overwrite, - /// Leave the existing file and skip the photo. - Skip, -} - -/// Where a batch's files go (Lightroom Classic's Export To). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DestinationMode { - /// One folder for the whole batch ([`Destination::dir`]). - #[default] - SpecificFolder, - /// Each photo's own folder: the folder its original is in. A photo without one on disk (a - /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. - SameAsOriginal, -} - -impl DestinationMode { - /// `specificFolder` / `sameAsOriginal` (any case). - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "specificfolder" => Some(Self::SpecificFolder), - "sameasoriginal" => Some(Self::SameAsOriginal), - _ => None, - } - } -} - -impl Conflict { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "unique" => Some(Self::Unique), - "overwrite" => Some(Self::Overwrite), - "skip" => Some(Self::Skip), - _ => None, - } - } -} - -/// A named set of `app.export` params. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ExportPreset { - pub name: String, - pub params: serde_json::Value, -} - -/// The built-in export presets (File → Export with Preset). -pub fn builtin_presets() -> Vec { - let p = |name: &str, params: serde_json::Value| ExportPreset { name: name.into(), params }; - vec![ - p("JPEG (Small)", serde_json::json!({"format": "jpeg", "quality": 85, "longEdge": 2048, "colorSpace": "srgb"})), - p("JPEG (Large)", serde_json::json!({"format": "jpeg", "quality": 92, "longEdge": 0, "colorSpace": "srgb"})), - p("Original + Settings", serde_json::json!({"format": "original"})), - p("DNG", serde_json::json!({"format": "dng"})), - ] -} - -/// Output sharpening target (applied after resizing, in display-encoded values). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SharpenFor { - #[default] - None, - Screen, - Matte, - Glossy, -} - -impl SharpenFor { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "none" => Some(Self::None), - "screen" => Some(Self::Screen), - "matte" => Some(Self::Matte), - "glossy" => Some(Self::Glossy), - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SharpenAmount { - Low, - #[default] - Standard, - High, -} - -impl SharpenAmount { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "low" => Some(Self::Low), - "standard" => Some(Self::Standard), - "high" => Some(Self::High), - _ => None, - } - } -} - -/// Where a watermark sits. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Anchor { - TopLeft, - Top, - TopRight, - Left, - Center, - Right, - BottomLeft, - Bottom, - #[default] - BottomRight, -} - -/// A text watermark, sized relative to the image so every export size looks the same. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Watermark { - pub text: String, - /// Basic Japanese vertical lettering: upright glyphs in columns from right to left. - pub vertical: bool, - /// Text height as a fraction of the image's short edge. - pub size: f32, - /// 0..1. - pub opacity: f32, - pub anchor: Anchor, - /// Margin from the left or right edge, as a fraction of the short edge (unused when the anchor - /// is centred across: top, center, bottom). - pub inset_x: f32, - /// Margin from the top or bottom edge, as a fraction of the short edge (unused when the anchor - /// is centred down: left, center, right). - pub inset_y: f32, - /// Degrees clockwise: 0, 90, 180 or 270 (anything else draws unrotated). The watermark is - /// sized before it is turned, and the insets are measured to the turned watermark. - pub rotation: u16, - /// sRGB colour. - pub color: [u8; 3], - /// Soft dark drop shadow for legibility on bright areas. - pub shadow: bool, - /// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text). - pub image: String, - /// The graphic's width as a fraction of the photo's width. - pub image_width: f32, - /// The colour space the photo is in (set by the encoder; the graphic is converted into it). - #[serde(skip)] - pub target: Option, -} - -impl Default for Watermark { - fn default() -> Self { - Self { - text: String::new(), - vertical: false, - size: 0.035, - opacity: 0.7, - anchor: Anchor::BottomRight, - inset_x: 0.025, - inset_y: 0.025, - rotation: 0, - color: [255; 3], - shadow: true, - image: String::new(), - image_width: 0.2, - target: None, - } - } -} - -impl Watermark { - /// `rotation` in clockwise quarter turns. - fn quarter_turns(&self) -> u8 { - match self.rotation { - 90 => 1, - 180 => 2, - 270 => 3, - _ => 0, - } - } -} - -/// Where the point `(x, y)` of a `w × h` box lands when the box is turned `turns` quarter turns -/// clockwise (for pixel coordinates: the turned box is `h × w` for odd turns). -fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) { - match turns { - 1 => (h - 1.0 - y, x), - 2 => (w - 1.0 - x, h - 1.0 - y), - 3 => (y, w - 1.0 - x), - _ => (x, y), - } -} - #[cfg(test)] use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts}; -/// Draw `wm` onto `img` (straight alpha blending of the encoded values). -pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { - blend_watermark(img, wm, 1.0); -} +mod batch; +mod encoding; +mod options; +mod params; +mod place; +mod prepare; +mod watermark; -/// Draw `wm` onto `img`, a preview of an export whose short edge is `export_short_edge` pixels. -/// The smallest text size and shadow offset scale with the preview, so it looks like the file. -pub fn draw_watermark_preview(img: &mut Rgba8, wm: &Watermark, export_short_edge: usize) { - let short = img.width.min(img.height); - let scale = if export_short_edge == 0 { 1.0 } else { short as f32 / export_short_edge as f32 }; - blend_watermark(img, wm, scale); -} - -fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { - let width = img.width; - watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, floor_scale, |x, y, k, col| { - let p = &mut img.data[y * width + x]; - for c in 0..3 { - p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; - } - }); -} - -/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and -/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. -pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { - let (width, height) = (img.width, img.height); - watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { - let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); - if px >= width || py >= height { - return; - } - if let Some(p) = img.data.get_mut(py * width + px) { - for c in 0..3 { - p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; - } - } - }); -} - -/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float -/// images are linear and blend in linear light). -pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { - let (width, trc) = (img.width, img.space.trc()); - match &mut img.samples { - DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { - let i = (y * width + x) * 3; - for c in 0..3 { - let p = v[i + c] as f32; - v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; - } - }), - DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { - let i = (y * width + x) * 3; - for c in 0..3 { - let target = trc.decode(col[c] as f32 / 255.0); - v[i + c] += (target - v[i + c]) * k; - } - }), - } -} - -/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for -/// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter -/// so tests can render without it). `floor_scale` is this image's size over the exported file's -/// (1 when drawing the file itself): the 6 px minimum text size is a minimum in the file. -fn watermark_coverage( - width: usize, - height: usize, - wm: &Watermark, - craft: &'static [crate::fonts::CraftFont], - floor_scale: f32, - mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), -) { - if !wm.image.trim().is_empty() { - logo_coverage(width, height, wm, blend_px); - return; - } - let text = wm.text.trim(); - if text.is_empty() || width == 0 || height == 0 { - return; - } - let short = width.min(height) as f32; - let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); - let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; - let (tw, th) = (layout.width, layout.height); - // laid out unturned in a tw × th box, placed by the box it becomes - let turns = wm.quarter_turns(); - let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) }; - let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); - let (w, h) = (width as f32, height as f32); - use Anchor::*; - let ox = match wm.anchor { - TopLeft | Left | BottomLeft => ix, - Top | Center | Bottom => (w - bw) / 2.0, - TopRight | Right | BottomRight => w - ix - bw, - }; - let oy = match wm.anchor { - TopLeft | Top | TopRight => iy, - Left | Center | Right => (h - bh) / 2.0, - BottomLeft | Bottom | BottomRight => h - iy - bh, - }; - let alpha = wm.opacity.clamp(0.0, 1.0); - let passes: &[(f32, [u8; 3], f32)] = - if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; - for &(off, col, a) in passes { - let mut put = |gx: i32, gy: i32, cov: f32| { - if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { - return; - } - blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); - }; - if turns == 0 { - layout.draw_in(ox + off, oy + off, w, h, &mut put); - } else { - // turned with the box; the shadow still falls down and to the right - layout.draw_points(|x, y, cov| { - let (x, y) = turn(turns, x, y, tw, th); - put((ox + off + x) as i32, (oy + off + y) as i32, cov); - }); - } - } -} - -/// A watermark graphic, decoded once per file (and again when the file changes). -pub(crate) fn watermark_logo(path: &str) -> Option> { - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - static CACHE: Mutex)>>> = Mutex::new(None); - let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - let map = c.get_or_insert_with(HashMap::new); - if let Some((m, img)) = map.get(path) - && *m == modified - { - return Some(img.clone()); - } - let img = Arc::new(read_graphic(path).ok()?); - map.insert(path.to_string(), (modified, img.clone())); - Some(img) -} - -/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the -/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it. -/// The error says why it couldn't be read or decoded. -pub fn read_graphic(path: &str) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; - let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; - Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation))) -} - -/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to -/// `image_width` of the photo, placed like the text, blended with its alpha × opacity. -fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { - let Some(logo) = watermark_logo(wm.image.trim()) else { - log::warn!("watermark: can't read {}", wm.image); - return; - }; - if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { - return; - } - let turns = wm.quarter_turns(); - // Reserve the inset on anchored axes before fitting the turned box. Centered axes - // ignore insets, just as placement below does. One scale preserves the graphic's shape. - let short = width.min(height) as f32; - let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); - use Anchor::*; - let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 }; - let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 }; - let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) }; - let scale = - (wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64); - let lw = (logo.width as f64 * scale).round().max(1.0) as usize; - let lh = (logo.height as f64 * scale).round().max(1.0) as usize; - let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { - let p = logo.get(x, y); - let a = p[3] as f32 / 255.0; - [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] - }); - let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); - let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; - let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); - let ox = match wm.anchor { - TopLeft | Left | BottomLeft => ix, - Top | Center | Bottom => (w - lwf) / 2.0, - TopRight | Right | BottomRight => w - ix - lwf, - } - .max(0.0) as usize; - let oy = match wm.anchor { - TopLeft | Top | TopRight => iy, - Left | Center | Right => (h - lhf) / 2.0, - BottomLeft | Bottom | BottomRight => h - iy - lhf, - } - .max(0.0) as usize; - let space = wm.target.unwrap_or(OutputSpace::Srgb); - let op = wm.opacity.clamp(0.0, 1.0); - for y in 0..lh { - for x in 0..lw { - let [r, g, b, a] = scaled.get(x, y); - let a = a.clamp(0.0, 1.0); - let (tx, ty) = turn(turns, x as f32, y as f32, lw as f32, lh as f32); - let (px, py) = (ox + tx as usize, oy + ty as usize); - if a <= 1e-4 || px >= width || py >= height { - continue; - } - let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); - blend_px(px, py, a * op, srgb8_in(space, c)); - } - } -} - -/// Which metadata is embedded in exported files. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MetadataPolicy { - /// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright. - #[default] - All, - /// Everything except camera/lens make, model and capture settings. - AllExceptCamera, - /// Copyright and creator only. - Copyright, - /// Nothing (the sRGB profile is still embedded). - None, -} - -impl MetadataPolicy { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "all" => Some(Self::All), - "allexceptcamera" => Some(Self::AllExceptCamera), - "copyright" => Some(Self::Copyright), - "none" => Some(Self::None), - _ => None, - } - } -} - -/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or -/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum HdrCurve { - #[default] - Pq, - Hlg, -} - -impl HdrCurve { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "pq" => Some(Self::Pq), - "hlg" => Some(Self::Hlg), - _ => None, - } - } - fn codec(self) -> lightcraft_codecs::HdrTransfer { - match self { - Self::Pq => lightcraft_codecs::HdrTransfer::Pq, - Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg, - } - } -} +#[cfg(test)] +use batch::run_batch_with; +pub use batch::{export_parallelism, run_batch}; +pub use encoding::{ + encode_deep, encode_gain_map_jpeg, encode_image, encode_rendered, encode_with_metadata, export_metadata, named_space, output_sharpen, + output_sharpen_deep, removes_location, srgb8_in, +}; +pub use options::{ + Conflict, DestinationMode, ExportFormat, ExportPreset, HdrCurve, MAX_ENLARGED_PIXELS, MetadataPolicy, Resize, ResizeMode, SharpenAmount, + SharpenFor, builtin_presets, +}; +pub use params::{ + OPTION_PARAMS, TARGET_PARAMS, WATERMARK_IMAGE_WIDTH_RANGE, WATERMARK_INSET_RANGE, WATERMARK_PARAMS, WATERMARK_SIZE_RANGE, + apple_photos_unsupported, +}; +#[cfg(test)] +use place::Placer; +pub use place::{write_file, write_file_durable, write_file_new}; +pub use prepare::{ + AfterExport, Destination, DngCompression, Exported, PreparedExport, export_batch, export_photo, output_size, prepare_batch, prepare_export, +}; +#[cfg(test)] +use prepare::{render_weight, too_big_for_memory}; +#[cfg(test)] +use watermark::watermark_coverage; +pub(crate) use watermark::watermark_logo; +pub use watermark::{Anchor, Watermark, draw_watermark, draw_watermark_deep, draw_watermark_in, draw_watermark_preview, read_graphic}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -686,3138 +114,5 @@ pub struct ExportOptions { pub hdr_transfer: HdrCurve, } -impl Default for ExportOptions { - fn default() -> Self { - Self { - format: ExportFormat::Jpeg, - quality: 90, - resize: None, - ppi: 240, - limit_kb: None, - sharpen: SharpenFor::None, - sharpen_amount: SharpenAmount::Standard, - naming: "{name}".into(), - start_number: 1, - destination_mode: DestinationMode::SpecificFolder, - subfolder: String::new(), - conflict: Conflict::Unique, - tiff_compression: TiffCompression::Deflate, - dng_compression: DngCompression::Lossless, - metadata: MetadataPolicy::All, - remove_location: false, - watermark: None, - color_space: OutputSpace::Srgb, - bit_depth: None, - hdr: false, - jxl_lossless: false, - jxl_effort: JxlEffort::Normal, - jxl_progressive: false, - add_to_photos: false, - photos_album: String::new(), - hdr_transfer: HdrCurve::Pq, - } - } -} - -/// The `app.export` params that are not export options: which photos, where to, a preset to start -/// from, and the desktop app's background flag. -pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"]; - -/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes). -pub const OPTION_PARAMS: &[&str] = &[ - "format", - "quality", - "resize", - "longEdge", - "shortEdge", - "width", - "height", - "megapixels", - "percent", - "fit", - "dontEnlarge", - "ppi", - "limitKb", - "sharpen", - "sharpenAmount", - "naming", - "startNumber", - "destinationMode", - "subfolder", - "conflict", - "tiffCompression", - "dngCompression", - "metadata", - "removeLocation", - "watermark", - "colorSpace", - "bitDepth", - "hdr", - "jxlLossless", - "jxlEffort", - "jxlProgressive", - "addToPhotos", - "photosAlbum", - "hdrTransfer", -]; - -/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the -/// session has the runner; see [`crate::Session::after_export`]). -pub fn apple_photos_unsupported() -> Option<&'static str> { - (!cfg!(target_os = "macos")).then_some("Apple Photos is only available on macOS") -} - -/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY` -/// together (what versions before the two margins wrote); either of those, when given, wins. -pub const WATERMARK_PARAMS: &[&str] = - &["text", "vertical", "size", "opacity", "anchor", "insetX", "insetY", "inset", "rotation", "color", "shadow", "image", "imageWidth"]; - -/// Text height as a fraction of the short edge: the range the renderer draws without clamping. -pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); -/// Margin as a fraction of the short edge. -pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4); -/// A graphic's width as a fraction of the photo's width. -pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0); - -/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short). -fn edit_distance(a: &str, b: &str) -> usize { - let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); - let mut prev: Vec = (0..=b.len()).collect(); - for (i, ca) in a.iter().enumerate() { - let mut cur = vec![i + 1; b.len() + 1]; - for (j, cb) in b.iter().enumerate() { - let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb); - let del = prev.get(j + 1).copied().unwrap_or(0) + 1; - let ins = cur.get(j).copied().unwrap_or(0) + 1; - if let Some(c) = cur.get_mut(j + 1) { - *c = sub.min(del).min(ins); - } - } - prev = cur; - } - prev.last().copied().unwrap_or(0) -} - -/// `unknown parameter \`k\``, naming the closest known key when one is near. -fn unknown_key(what: &str, k: &str, known: &[&str]) -> String { - let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min(); - match near { - Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"), - None => format!("unknown {what} `{k}` (one of {})", known.join(", ")), - } -} - -/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them. -fn whole(v: &serde_json::Value) -> Option { - v.as_i64() -} - -/// A `watermark` object as a [`Watermark`]; a legacy `inset` fills whichever of `insetX` and -/// `insetY` is missing. -fn watermark_from_json(v: &serde_json::Value) -> serde_json::Result { - let mut v = v.clone(); - if let Some(o) = v.as_object_mut() - && let Some(inset) = o.remove("inset") - { - o.entry("insetX").or_insert_with(|| inset.clone()); - o.entry("insetY").or_insert(inset); - } - serde_json::from_value(v) -} - -impl ExportOptions { - /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or - /// newer version may have written and this one doesn't know dropped, also inside `watermark`. - /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are - /// not the caller's typo, and refusing them would make a preset or Export with Previous fail - /// for good after an update. A `bitDepth` the stored format doesn't have (written at the - /// format's own depth before issue #1952) is dropped too. - pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { - let mut p = Self::known_keys(p); - Self::drop_unfit_depth(&mut p, ExportFormat::default()); - p - } - - /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be - /// known once the call's own params (`path`) are merged in. - pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value { - let mut p = p.clone(); - if let Some(o) = p.as_object_mut() { - o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); - if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) { - w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str())); - } - } - p - } - - /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with - /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952. - pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) { - let format = Self::export_format(p, fallback); - if let Some(o) = p.as_object_mut() - && let Some(depth) = o.get("bitDepth").and_then(whole) - && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth) - { - o.remove("bitDepth"); - } - } - - /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an - /// unknown parameter or a value that cannot be read as what it is for is an error, never a - /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) - /// are accepted and left to the caller. - pub fn from_params(p: &serde_json::Value) -> crate::Result { - Self::from_params_or(p, ExportFormat::default()) - } - - /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no - /// format; the format returned is [`ExportOptions::export_format`], the one written. - pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result { - let format = Self::export_format(p, fallback); - Self::validate_as("app.export", p, Some(format))?; - Ok(Self { format, ..Self::from_json(p) }) - } - - /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of - /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there, - /// the headless backend PNG), else the default. - pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat { - use serde_json::Value; - match (p.get("format"), p.get("path").and_then(Value::as_str)) { - (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(), - (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback), - (None, None) => ExportFormat::default(), - } - } - - /// Check `app.export` params for `cmd` (the command named in the error): every key must be an - /// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the - /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] - /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. - pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { - Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default()))) - } - - /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported - /// (`None`: not known yet, as for a preset without `format`). - pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> { - use serde_json::Value; - let bad = |msg: String| crate::cmd::bad(cmd, msg); - let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; - let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect(); - let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result { - whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}"))) - }; - let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result { - v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}"))) - }; - fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> { - v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string"))) - } - let boolean = |k: &str, v: &Value| -> crate::Result { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) }; - let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> { - let s = string(cmd, k, v)?; - if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } - }; - // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported - let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { - if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } - }; - for (k, v) in obj { - if v.is_null() { - continue; - } - match k.as_str() { - "format" => { - if ExportFormat::parse(string(cmd, k, v)?).is_none() { - return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|jxl|dng|original"))); - } - } - "quality" => { - int(k, v, 1, 100)?; - } - "ppi" => { - int(k, v, 1, u16::MAX as i64)?; - } - "limitKb" => { - int(k, v, 0, u32::MAX as i64)?; - } - "startNumber" => { - int(k, v, 0, 999_999)?; - } - "bitDepth" => { - if !matches!(whole(v), Some(8 | 10 | 16 | 32)) { - return Err(bad(format!("`{k}` must be 8, 10, 16 or 32"))); - } - } - "longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => { - num(k, v, 0.0, 1.0e9)?; - } - "fit" => one_of(k, v, &["dimensions", "widthHeight"])?, - "resize" => { - let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) }; - if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) { - return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"]))); - } - serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; - } - "dontEnlarge" | "removeLocation" | "background" | "hdr" | "jxlLossless" | "jxlProgressive" => { - boolean(k, v)?; - } - "addToPhotos" => { - if boolean(k, v)? - && let Some(why) = apple_photos_unsupported() - { - return Err(bad(format!("`{k}`: {why}"))); - } - } - "photosAlbum" => { - if string(cmd, k, v)?.contains('\0') { - return Err(bad(format!("`{k}` can't contain a NUL character"))); - } - } - "naming" | "subfolder" | "path" | "dir" | "preset" => { - string(cmd, k, v)?; - } - "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, - "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, - "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, - "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, - "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, - "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, - "jxlEffort" => one_of(k, v, &["fast", "normal"])?, - "dngCompression" => { - serde_json::from_value::(v.clone()) - .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; - } - "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, - "colorSpace" => { - if OutputSpace::parse(string(cmd, k, v)?).is_none() { - return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); - } - } - "watermark" => Self::validate_watermark(cmd, v)?, - "id" => { - int(k, v, 0, i64::MAX)?; - } - "ids" => { - let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64())); - if !ok { - return Err(bad(format!("`{k}` must be an array of photo ids"))); - } - } - _ => return Err(bad(unknown_key("parameter", k, &known))), - } - } - // issue #1952: a depth the format doesn't have would be written at the format's own depth - if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) { - let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect(); - if !depths.iter().any(|d| i64::from(*d) == depth) { - depths.sort_unstable(); - let mut list: Vec = depths.iter().map(u8::to_string).collect(); - let last = list.pop().unwrap_or_default(); - let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) }; - let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - return Err(bad(format!("`bitDepth` must be {list} for {name}"))); - } - } - Ok(()) - } - - /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). - pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { - use serde_json::Value; - let bad = |msg: String| crate::cmd::bad(cmd, msg); - let obj = match v { - Value::String(_) => return Ok(()), - Value::Object(o) => o, - _ => { - return Err(bad( - "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}".into(), - )); - } - }; - let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> { - match v.as_f64() { - Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()), - _ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))), - } - }; - for (k, v) in obj { - if v.is_null() { - continue; - } - match k.as_str() { - "text" | "image" => { - v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?; - } - "vertical" | "shadow" => { - v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?; - } - "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, - "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, - "insetX" | "insetY" | "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, - "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, - "rotation" => { - if !matches!(whole(v), Some(0 | 90 | 180 | 270)) { - return Err(bad("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)".into())); - } - } - "anchor" => { - serde_json::from_value::(v.clone()).map_err(|_| { - bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into()) - })?; - } - "color" => { - let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255)))); - if !ok { - return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into())); - } - } - _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), - } - } - watermark_from_json(v).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) - } - - /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, - /// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`, - /// `addToPhotos`, `photosAlbum`) and the - /// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H), - /// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus - /// `dontEnlarge` (default true). - /// - /// Lenient: anything unknown or unreadable falls back to the default, which suits stored - /// prefs and dialog prefill. A command that is about to export uses - /// [`ExportOptions::from_params`], which validates first. - pub fn from_json(p: &serde_json::Value) -> Self { - use serde_json::Value; - let d = Self::default(); - let s = |k: &str| p.get(k).and_then(Value::as_str); - let u = |k: &str| p.get(k).and_then(Value::as_u64); - fn enm(p: &Value, k: &str) -> Option { - p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok()) - } - Self { - format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format), - quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8), - resize: Self::resize_from_json(p), - ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), - limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), - sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), - sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), - naming: s("naming").map_or(d.naming, str::to_string), - start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), - destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), - subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), - conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), - tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { - Some("none") => TiffCompression::None, - Some("lzw") => TiffCompression::Lzw, - Some("zip" | "deflate") => TiffCompression::Deflate, - _ => d.tiff_compression, - }, - dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), - metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), - remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), - watermark: match p.get("watermark") { - Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), - Some(v @ Value::Object(_)) => watermark_from_json(v).ok(), - _ => None, - } - .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), - color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), - bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), - hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr), - jxl_lossless: p.get("jxlLossless").and_then(Value::as_bool).unwrap_or(d.jxl_lossless), - jxl_effort: s("jxlEffort").and_then(JxlEffort::parse).unwrap_or(d.jxl_effort), - jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive), - add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos), - photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()), - hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer), - } - } - - fn resize_from_json(p: &serde_json::Value) -> Option { - use serde_json::Value; - let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| *v > 0.0); - let dont_enlarge = p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(true); - if let Some(r) = p.get("resize").filter(|v| v.is_object()) { - let r: Resize = serde_json::from_value(r.clone()).ok()?; - return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r }); - } - let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge }; - // `fit: widthHeight` binds each edge to its own number; the default fits either orientation - let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight")); - Some(match (f("width"), f("height")) { - (Some(w), Some(h)) => { - Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) } - } - (Some(w), None) => r(ResizeMode::Width, w), - (None, Some(h)) => r(ResizeMode::Height, h), - (None, None) => { - if let Some(v) = f("longEdge") { - r(ResizeMode::LongEdge, v) - } else if let Some(v) = f("shortEdge") { - r(ResizeMode::ShortEdge, v) - } else if let Some(v) = f("megapixels") { - r(ResizeMode::Megapixels, v) - } else { - let v = f("percent")?; - r(ResizeMode::Percent, v) - } - } - }) - } - - /// These options as `app.export` params ([`ExportOptions::from_json`] reads them back - /// unchanged; full size is written as `longEdge: 0`). - pub fn to_json(&self) -> serde_json::Value { - let mut v = serde_json::to_value(self).unwrap_or_default(); - if let Some(o) = v.as_object_mut() { - o.remove("resize"); - if let Some(sz) = Self::resize_json(self.resize.as_ref()).as_object() { - o.extend(sz.clone()); - } - if self.resize.is_none() { - o.insert("longEdge".into(), 0.into()); - } - o.retain(|_, v| !v.is_null()); - } - v - } - - /// Whether `p` names an output size (any of the size params, even 0 = full size). - pub fn has_size_param(p: &serde_json::Value) -> bool { - ["resize", "longEdge", "shortEdge", "width", "height", "megapixels", "percent"].iter().any(|k| p.get(k).is_some()) - } - - /// The size params of [`ExportOptions::from_json`] for `resize` (`{}` = full size). - pub fn resize_json(resize: Option<&Resize>) -> serde_json::Value { - match resize { - None => serde_json::json!({}), - Some(r) => serde_json::json!({"resize": r, "dontEnlarge": r.dont_enlarge}), - } - } - - /// The sample format the file is written with (unsupported requests fall back to the closest). - pub fn effective_depth(&self) -> OutputDepth { - match (self.format, self.bit_depth) { - (ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng, _) => OutputDepth::U8, - (ExportFormat::Png, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, - (ExportFormat::Png, _) => OutputDepth::U8, - (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, - (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, - (ExportFormat::Tiff, _) => OutputDepth::U16, - (ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::RenderedDng, _) => OutputDepth::F32Linear, - (ExportFormat::Psd, Some(8)) => OutputDepth::U8, - (ExportFormat::Psd, _) => OutputDepth::U16, - (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, - (ExportFormat::Avif, _) => OutputDepth::U8, - (ExportFormat::Jxl, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Jxl, Some(10 | 16 | 32)) => OutputDepth::U16, - (ExportFormat::Jxl, _) => OutputDepth::U8, - } - } - - /// The bit depths `format` offers (value, label); the first is its default. - pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] { - match format { - ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng => &[(8, "8-bit")], - ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], - ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], - ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], - ExportFormat::RenderedDng => &[(16, "16-bit float")], - ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], - ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")], - } - } - - /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF, - /// PNG or JPEG XL (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. - pub fn hdr_output(&self) -> bool { - self.hdr - && matches!( - (self.format, self.bit_depth), - (ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl, _) | (ExportFormat::Tiff, Some(32)) - ) - } - - /// The colour space the file is actually written in (AVIF: sRGB; AVIF, PNG and JPEG XL HDR: - /// Rec. 2020; lossy JPEG XL: Adobe RGB and ProPhoto as Rec. 2020, the widest space its encoder - /// can declare without a profile). - pub fn effective_space(&self) -> OutputSpace { - match self.format { - ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl if self.hdr => OutputSpace::Rec2020, - ExportFormat::Avif => OutputSpace::Srgb, - ExportFormat::Jxl if !self.jxl_lossless && matches!(self.color_space, OutputSpace::AdobeRgb | OutputSpace::ProPhoto) => { - OutputSpace::Rec2020 - } - _ => self.color_space, - } - } - - /// The JPEG XL encoder settings for these options, writing samples in `space`. - fn jxl(&self, space: OutputSpace, hdr: bool) -> JxlOptions { - let colour = match space { - _ if hdr => JxlColour::Rec2020Pq, - OutputSpace::DisplayP3 => JxlColour::DisplayP3, - OutputSpace::Rec2020 => JxlColour::Rec2020, - // lossless files carry the ICC profile; lossy ones never get here (`effective_space`) - OutputSpace::Srgb | OutputSpace::AdobeRgb | OutputSpace::ProPhoto => JxlColour::Srgb, - }; - JxlOptions { quality: self.quality, lossless: self.jxl_lossless, effort: self.jxl_effort, progressive: self.jxl_progressive, colour } - } - - /// Output file name for photo `p` at 1-based position `seq` in a batch (the original's - /// extension is kept for [`ExportFormat::Original`]). - pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String { - let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; - let n = seq + self.start_number.max(1) as usize - 1; - let name = crate::rename::expand_tokens(base, p, n, 3); - let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); - let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, "")); - if name.trim().is_empty() { - // every token came out empty (no title, no capture time…): keep the original name - name = stem.to_string(); - } - if self.format == ExportFormat::Original { - let base = name.trim_end_matches('.'); - if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") } - } else { - format!("{name}.{}", self.format.extension()) - } - } -} - -/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). -pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { - let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); - if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { - for (p, v) in img.data.iter_mut().zip(out) { - for c in 0..3 { - p[c] = v[c].round().clamp(0.0, 255.0) as u8; - } - } - } -} - -/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). -pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { - let (w, h) = (img.width, img.height); - match &mut img.samples { - DeepSamples::U16(v) => { - let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); - if let Some(out) = unsharp(w, h, &src, target, amount) { - for (d, s) in v.iter_mut().zip(out.as_flattened()) { - *d = s.round().clamp(0.0, 65535.0) as u16; - } - } - } - DeepSamples::F32(v) => { - let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(); - if let Some(out) = unsharp(w, h, &src, target, amount) { - for (d, s) in v.iter_mut().zip(out.as_flattened()) { - *d = s.max(0.0); - } - } - } - } -} - -/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. -fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { - let (radius, base) = match target { - SharpenFor::None => return None, - SharpenFor::Screen => (1usize, 0.35f32), - SharpenFor::Matte => (2, 0.6), - SharpenFor::Glossy => (1, 0.5), - }; - let k = base - * match amount { - SharpenAmount::Low => 0.6, - SharpenAmount::Standard => 1.0, - SharpenAmount::High => 1.5, - }; - if w < 3 || h < 3 { - return None; - } - let r = radius as isize; - let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { - let mut out = vec![[0.0; 3]; inp.len()]; - for y in 0..h { - for x in 0..w { - let mut acc = [0.0f32; 3]; - for d in -r..=r { - let (sx, sy) = if horizontal { - ((x as isize + d).clamp(0, w as isize - 1) as usize, y) - } else { - (x, (y as isize + d).clamp(0, h as isize - 1) as usize) - }; - let p = inp[sy * w + sx]; - acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; - } - let n = (2 * r + 1) as f32; - out[y * w + x] = acc.map(|v| v / n); - } - } - out - }; - let blur = box_pass(&box_pass(src, true), false); - Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect()) -} - -/// Encode a rendered display-referred image according to `o` (its pixels must already be in -/// `o.effective_space()`). Resizing to `long_edge` is the -/// caller's job (render at that size); sharpening is applied here. -pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { - encode_with_metadata(img, o, None) -} - -/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP. -pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let img = finish_8bit(img, o); - let space = o.effective_space(); - let profile = icc::write_named(named_space(space)); - let exif = meta.map(lightcraft_meta::write_exif); - let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - // Opaque photo exports need no alpha channel (#1670). Keep real transparency for - // callers of this encoder, and leave unrelated formats on their existing path. - let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) - && img.data.iter().all(|p| p.last() == Some(&255))) - .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); - let e = match &rgb { - Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), - None => EncodeImage::rgba8(&img), - }; - let r = match o.format { - ExportFormat::Jpeg => { - // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). - let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta)); - } - ExportFormat::Png => encode::encode_png(&e, &meta), - ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), - ExportFormat::Psd => encode::encode_psd(&e, &meta), - ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), - ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), - ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta), - f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), - ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()), - }; - r.map_err(|e| e.to_string()) -} - -/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs. -fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result>) -> Result, String> { - let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) }; - let limit = u64::from(kb) * 1024; - let (mut lo, mut hi) = (1u8, o.quality.min(100)); - let mut best = None; - while lo <= hi { - let mid = lo + (hi - lo) / 2; - let b = jpeg(mid).map_err(|e| e.to_string())?; - if b.len() as u64 <= limit { - best = Some(b); - lo = mid + 1; - } else if mid == 1 { - break; - } else { - hi = mid - 1; - } - } - best.ok_or_else(|| format!("cannot fit within {kb} KB")) -} - -/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. -fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { - let mut img = img.clone(); - output_sharpen(&mut img, o.sharpen, o.sharpen_amount); - let space = o.effective_space(); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; - draw_watermark(&mut img, &wm); - } - img -} - -/// Output sharpening and the watermark on a high-bit-depth render. -fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { - let mut img = img.clone(); - output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; - draw_watermark_deep(&mut img, &wm); - } - img -} - -/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) -/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured -/// against the base as written, so its 8-bit rounding is handed back by the map. -pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - use lightcraft_codecs::gainmap; - let base = finish_8bit(sdr, o); - let hdr = finish_deep(hdr, o); - let DeepSamples::F32(hv) = &hdr.samples else { - return Err("HDR export needs a float HDR render".into()); - }; - let (w, h) = (base.width, base.height); - if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 { - return Err("HDR and SDR renders differ in size".into()); - } - let space = o.effective_space(); - let trc = space.trc(); - let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect(); - let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0; - let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?; - let profile = icc::write_named(named_space(space)); - let exif = meta.map(lightcraft_meta::write_exif); - let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em)) -} - -/// The codecs' name of an output space (for its ICC profile). -pub fn named_space(s: OutputSpace) -> NamedSpace { - match s { - OutputSpace::Srgb => NamedSpace::Srgb, - OutputSpace::DisplayP3 => NamedSpace::DisplayP3, - OutputSpace::AdobeRgb => NamedSpace::AdobeRgb, - OutputSpace::ProPhoto => NamedSpace::ProPhoto, - OutputSpace::Rec2020 => NamedSpace::Rec2020, - } -} - -/// An 8-bit sRGB colour expressed in `space` (encoded with its curve). -pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { - if space == OutputSpace::Srgb { - return c; - } - let lin = c.map(lightcraft_color::transfer::decode_srgb8); - let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32(); - let t = space.trc(); - std::array::from_fn(|i| { - let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]; - (t.encode(v) * 255.0 + 0.5) as u8 - }) -} - -/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF, -/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image. -pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - match &r.deep { - Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta), - _ => encode_with_metadata(&r.image, o, meta), - } -} - -/// Encode a high-bit-depth image (see [`encode_rendered`]). -pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let meta_src = meta; - let img = finish_deep(img, o); - let profile = match img.samples { - DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), - DeepSamples::U16(_) => icc::write_named(named_space(img.space)), - }; - let exif = meta.map(lightcraft_meta::write_exif); - let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let (w, h) = (img.width as u32, img.height as u32); - let e = match &img.samples { - DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), - DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), - }; - let r = match (o.format, &img.samples) { - (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta), - (ExportFormat::Png, _) => encode::encode_png(&e, &meta), - (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), - (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), - (ExportFormat::RenderedDng, DeepSamples::F32(v)) => { - let m = meta_src.cloned().unwrap_or_default(); - return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); - } - (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { - lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec()) - } - (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), - (ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => { - lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, true), &meta) - } - (ExportFormat::Jxl, DeepSamples::U16(_)) => lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, false), &meta), - (f, _) => return Err(format!("{f:?} export is 8-bit only")), - }; - r.map_err(|e| e.to_string()) -} - -/// Whether the export of `photo` under `o` leaves out its location (GPS and place names). -/// (`photo` and `catalog` let library-level privacy rules add to the export option.) -pub fn removes_location(_photo: &lightcraft_catalog::Photo, _catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> bool { - o.remove_location -} - -/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. -/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). -pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { - let m = &photo.meta; - let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); - // copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL - let mut out = Metadata { - copyright: text(&m.copyright), - copyright_marked: m.copyright_status.marked(), - usage_terms: text(&m.usage_terms), - copyright_url: text(&m.copyright_url), - artist: text(&m.creator), - software: Some("LightCraft".into()), - ..Default::default() - }; - match o.metadata { - MetadataPolicy::None => return None, - MetadataPolicy::Copyright => return Some(out), - MetadataPolicy::All | MetadataPolicy::AllExceptCamera => {} - } - out.title = text(&m.title); - out.caption = text(&m.caption); - out.alt_text = text(&m.alt_text); - out.extended_description = text(&m.extended_description); - if !o.remove_location { - out.sublocation = text(&m.location); - out.city = text(&m.city); - out.state = text(&m.state); - out.country = text(&m.country); - } - let keywords = catalog.export_keywords(&m.keywords); - out.keywords = keywords.flat; - out.hierarchical_keywords = keywords.hierarchical; - out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); - out.rating = (photo.rating > 0).then_some(photo.rating as i8); - // Pixels are exported upright: orientation is baked in. - out.orientation = Some(lightcraft_meta::Orientation::Normal); - if !o.remove_location { - out.gps = m.gps.map(|(latitude, longitude)| Gps { latitude, longitude, altitude: None }); - } - if o.metadata == MetadataPolicy::All { - out.model = text(&m.camera); - out.lens_model = text(&m.lens); - out.focal_length = m.focal_mm.map(f64::from); - out.f_number = m.aperture.map(f64::from); - out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter); - out.iso = m.iso; - } - Some(out) -} - -/// One exported file. -pub struct Exported { - pub file_name: String, - pub bytes: Vec, - pub width: usize, - pub height: usize, - /// Files written next to it, named like it with another extension: `(extension, bytes)` (the - /// XMP sidecar of an `Original` export). - pub sidecars: Vec<(&'static str, Vec)>, - /// Set when the render was tried on the GPU and fell back to the CPU: why (see - /// `docs/gpu-pipeline.md`). The file holds the CPU render either way. - pub gpu_fallback: Option, -} - -/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). -pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) { - let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); - // whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie - let (w, h) = (w.round().max(1.0), h.round().max(1.0)); - match &o.resize { - Some(r) => r.apply(w, h), - None => (w as usize, h as usize), - } -} - -/// Render photo `id` at the requested size and encode it (or copy / convert its original for -/// [`ExportFormat::Original`] / [`ExportFormat::Dng`]). -pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { - prepare_export(session, id, o, seq)?.run() -} - -/// One photo's export, set up from the session ([`prepare_export`]). [`PreparedExport::run`] does -/// the heavy part (read, decode, render, encode) without the session, so it can run on another -/// thread. -pub struct PreparedExport { - pub photo: lightcraft_catalog::PhotoId, - pub file_name: String, - /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` - /// for a demo photo or one in the browser's storage. - pub source_dir: Option, - work: Work, - /// The library's originals, which [`run_batch`] never writes over (shared by a batch). - guard: std::sync::Arc, - /// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`] - /// while it runs beside other photos of a batch. - weight: usize, -} - -struct RenderWork { - job: crate::media::RenderJob, - /// The HDR rendition of a gain map JPEG (`job` renders its SDR base). - hdr_job: Option, - meta: Option, - opts: ExportOptions, -} - -enum Work { - Render(Box), - /// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the - /// raw data re-encoded as a lossless DNG with the edits in its XMP). - File { - path: String, - read: Option, - packet: String, - dng: Option, - /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). - quality: u8, - lossy_edge: Option, - /// The original must not carry a location ([`removes_location`]). - remove_location: bool, - label: String, - size: (usize, usize), - }, -} - -/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use -/// [`prepare_batch`]: it looks at the library's originals once.) -pub fn prepare_export( - session: &mut crate::Session, - id: lightcraft_catalog::PhotoId, - o: &ExportOptions, - seq: usize, -) -> Result { - let guard = std::sync::Arc::new(session.original_guard()); - prepare_guarded(session, id, o, seq, guard) -} - -/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1). -pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result, String> { - let guard = std::sync::Arc::new(session.original_guard()); - ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect() -} - -fn prepare_guarded( - session: &mut crate::Session, - id: lightcraft_catalog::PhotoId, - o: &ExportOptions, - seq: usize, - guard: std::sync::Arc, -) -> Result { - let p = session.catalog.photo(id).ok_or("no such photo")?; - let file_name = o.file_name_for(p, seq); - let source_dir = original_folder(&p.source); - // HDR output only for photos edited in HDR; the rest of the batch exports as usual - let sdr_opts; - let o = if o.hdr && !p.develop.hdr.enabled { - sdr_opts = ExportOptions { hdr: false, ..o.clone() }; - &sdr_opts - } else { - o - }; - let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize); - let (work, weight) = if o.format.is_rendered() { - let (w, h) = output_size(p, o); - let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); - let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize; - let label = p.file_name.clone(); - let meta = export_metadata(p, &session.catalog, o); - let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg; - let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; - let weight = render_weight(full_px, long, job.level, w.saturating_mul(h)); - if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) { - return Err(format!("{label}: {why}")); - } - let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None }; - (Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight) - } else { - let lightcraft_catalog::Source::File { path } = &p.source else { - return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); - }; - let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression); - // the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again - let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 }); - let work = Work::File { - path: path.clone(), - read: session.media.file_bytes.clone(), - packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), - dng, - quality: o.quality, - lossy_edge: o.resize.is_some().then(|| { - let (w, h) = output_size(p, o); - u32::try_from(w.max(h)).unwrap_or(u32::MAX) - }), - remove_location: removes_location(p, &session.catalog, o), - label: p.file_name.clone(), - size: (p.width as usize, p.height as usize), - }; - (work, weight) - }; - Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) -} - -/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo -/// photo, and on the web, where an original lives in the browser's storage, not in a folder. -fn original_folder(source: &lightcraft_catalog::Source) -> Option { - let lightcraft_catalog::Source::File { path } = source else { return None }; - if cfg!(target_arch = "wasm32") { - return None; - } - let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); - (!dir.is_empty()).then_some(dir) -} - -/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size, -/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export -/// enlarged past the photo's size that needs more memory than the computer has would fail to -/// allocate, which ends the process rather than the export. Full-size and smaller exports, and -/// computers whose memory isn't known, are never refused. -fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option) -> Option { - let ram = ram?; - if out.0.saturating_mul(out.1) <= native_px || weight <= ram { - return None; - } - let gb = |b: usize| b as f64 / (1u64 << 30) as f64; - Some(format!( - "exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size", - out.0, - out.1, - gb(weight).ceil(), - gb(ram) - )) -} - -/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level -/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the -/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export -/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB. -fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize { - let edge = level.max_edge().min(long.max(1)); - let scale = edge as f64 / long.max(1) as f64; - let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize; - src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36)) -} - -impl PreparedExport { - pub fn run(self) -> Result { - let file_name = self.file_name; - match self.work { - Work::Render(w) => { - let RenderWork { job, hdr_job, meta, opts } = *w; - let result = job.run(); - let r = result.rendered?; - let gpu_fallback = result.gpu_fallback; - let bytes = match hdr_job { - Some(hj) => { - let hr = hj.run().rendered?; - let hdr = hr.deep.ok_or("the HDR render has no float samples")?; - encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())? - } - None => encode_rendered(&r, &opts, meta.as_ref())?, - }; - Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) - } - Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { - let bytes = match &read { - Some(r) => r(&path)?, - None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, - }; - let Some(dng) = dng else { - // the file is copied byte for byte: refuse rather than leak a location it embeds - if remove_location { - let metadata = lightcraft_meta::try_extract(&bytes) - .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?; - if metadata.gps.is_some() { - return Err(format!( - "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" - )); - } - } - return Ok(Exported { - file_name, - bytes, - width: size.0, - height: size.1, - sidecars: vec![("xmp", packet.into_bytes())], - gpu_fallback: None, - }); - }; - if lightcraft_raw::probe(&bytes).is_none() { - return Err(format!("{label}: DNG export needs a raw photo")); - } - let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; - crate::camera_preview::with_starting_colour(&mut raw, &bytes); - drop(bytes); - let compression = match dng { - DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, - DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, - DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, - DngCompression::Lossy => { - lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } - } - }; - let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) - .map_err(|e| e.to_string())?; - Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None }) - } - } - } -} - -/// How an exported DNG stores its raw data. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DngCompression { - /// Lossless JPEG (the usual DNG compression). - #[default] - Lossless, - /// Deflate (zip) tiles with prediction. - Deflate, - Uncompressed, - /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize - /// setting when there is one (Lightroom's "Use Lossy Compression"). - Lossy, -} - -/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy -/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each -/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file -/// already there is replaced). Either way a catalogued original (or its sidecar) -/// is never written over: see [`crate::originals`]. -#[derive(Clone, Debug, Default)] -pub struct Destination { - pub dir: String, - pub exact: Option, -} - -/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or -/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by -/// [`crate::Session::after_export`] before the export starts, so an export asking for something -/// this session can't do fails before writing anything; for Photos it holds the import slot -/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when -/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its -/// worker. -#[derive(Default)] -pub struct AfterExport { - #[cfg(not(target_arch = "wasm32"))] - apple_photos: Option, -} - -impl AfterExport { - /// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving - /// the import slot of `imports` now. (Front ends get this from - /// [`crate::Session::after_export`], which checks the platform first.) - #[cfg(not(target_arch = "wasm32"))] - pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result { - Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) }) - } - - /// Nothing to do. - #[cfg(not(target_arch = "wasm32"))] - pub fn is_none(&self) -> bool { - self.apple_photos.is_none() - } - - /// Nothing to do. - #[cfg(target_arch = "wasm32")] - pub fn is_none(&self) -> bool { - true - } - - /// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written - /// count), waiting for Photos: for a worker thread (the app's background export) or a caller - /// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the - /// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false, - /// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the - /// export was cancelled or wrote nothing). Never fails the export: its files are written - /// either way. - pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = self.apple_photos { - return Some(r.run(files, cancelled)); - } - let _ = (files, cancelled); - None - } - - /// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the - /// outcome is the running job (`{job, running: true, requested, album}`; its result comes - /// from `export.photosImports`, and the desktop app announces it). For a caller on the UI - /// thread, which must not wait minutes for Photos. - pub fn start(self, files: &[serde_json::Value]) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = self.apple_photos { - return Some(r.start(files)); - } - let _ = files; - None - } - - /// [`AfterExport::start`] where the session answers on a UI thread - /// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`]. - pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if s.apple_photos_background { - return self.start(files); - } - let _ = s; - self.run(files, false) - } -} - -impl crate::Session { - /// What an export with `o` does after writing its files; an error, before anything is written, - /// when it asks for Apple Photos and this platform or session has no way to reach it, or - /// another import is reserved or running. For Photos the import slot is reserved from now on. - pub fn after_export(&self, o: &ExportOptions) -> Result { - if !o.add_to_photos { - return Ok(AfterExport::default()); - } - if let Some(why) = apple_photos_unsupported() { - return Err(why.into()); - } - #[cfg(not(target_arch = "wasm32"))] - { - let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?; - let mut after = AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)?; - after.apple_photos = after.apple_photos.map(|r| r.shown_in(self.activity.clone())); - Ok(after) - } - #[cfg(target_arch = "wasm32")] - Err("Apple Photos is only available on macOS".into()) - } -} - -/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error. -pub fn export_batch( - session: &mut crate::Session, - ids: &[lightcraft_catalog::PhotoId], - o: &ExportOptions, - to: &Destination, - write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &dyn Fn(&str) -> bool, -) -> Result, String> { - let items = prepare_batch(session, ids, o)?; - run_batch(items, o, to, write, exists, true, &mut |_, _| true) -} - -/// 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 -/// renamed into place), so a failure part-way leaves any previous file intact and no truncated -/// one. Not synced to disk (issue #134): an export can always be made again from the original. -/// The native writer behind exports, renders and screenshots (app, CLI, MCP). -pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { - write_with(path, bytes, false) -} - -/// [`write_file`], synced to disk: for a written file that becomes a library photo (Edit a Copy) -/// and so can't simply be exported again once the catalog points at it. -pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> { - write_with(path, bytes, true) -} - -fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> { - use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync}; - let p = std::path::Path::new(path); - if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - } - if durable { write_atomic(p, bytes) } else { write_atomic_nosync(p, bytes) }.map_err(|e| format!("{path}: {e}")) -} - -/// The path of the sidecar with extension `ext` of the exported file `main`. -fn sidecar_path(main: &str, ext: &str) -> String { - std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string() -} - -/// 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 -/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when -/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the -/// policy. `progress(done, next file)` is called before each photo; returning false cancels the -/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`, -/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`. -/// -/// Several photos render side by side ([`export_parallelism`], within the memory of -/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's -/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the -/// files are the same as one at a time. -pub fn run_batch( - items: Vec, - o: &ExportOptions, - to: &Destination, - write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &dyn Fn(&str) -> bool, - stop_on_error: bool, - progress: &mut dyn FnMut(usize, &str) -> bool, -) -> Result, String> { - let lanes = export_parallelism(items.len()); - run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes) -} - -/// How many photos of a batch of `n` render side by side. Each render already spreads its rows -/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU. -/// One on wasm32 (no threads). -pub fn export_parallelism(n: usize) -> usize { - if cfg!(target_arch = "wasm32") { - return 1; - } - let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); - (cores / 4).clamp(1, 4).min(n.max(1)) -} - -/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them). -fn run_batch_with( - items: Vec, - placer: &mut Placer<'_>, - progress: &mut dyn FnMut(usize, &str) -> bool, - lanes: usize, -) -> Result, String> { - placer.single = items.len() == 1; - if lanes <= 1 || items.len() <= 1 { - for (i, item) in items.into_iter().enumerate() { - if !progress(i, &item.file_name) { - break; - } - let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); - placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; - } - return Ok(std::mem::take(&mut placer.out)); - } - let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); - let queue = Queue { - lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), - cv: std::sync::Condvar::new(), - ahead: lanes, - }; - std::thread::scope(|sc| { - // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer - // would otherwise leave them waiting and the scope's join would never return - let _stop = StopOnDrop(&queue); - for k in 1..lanes { - let q = &queue; - // a lane that can't start leaves its photos to the others (and to this thread) - if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() { - break; - } - } - let placed = || { - for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { - if !progress(i, &name) { - break; - } - let e = queue.result(i); - placer.place(photo, name, source_dir.as_deref(), &guard, e)?; - } - Ok::<_, String>(()) - }; - placed() - })?; - Ok(std::mem::take(&mut placer.out)) -} - -/// Stops a [`Queue`] when dropped, also while unwinding from a panic. -struct StopOnDrop<'a>(&'a Queue); - -impl Drop for StopOnDrop<'_> { - fn drop(&mut self) { - self.0.stop(); - } -} - -/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic -/// becomes that photo's error, not the batch's end. -fn run_gated(item: PreparedExport) -> Result { - let _held = crate::memory::export_gate().acquire(item.weight); - let what = format!("exporting {}", item.file_name); - crate::guard::catch(&what, || item.run()).and_then(|r| r) -} - -/// The photos of a batch shared by its lanes: each lane takes the next one, at most -/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for -/// their turn too (their working memory is given back to the gate as soon as they are encoded). -struct Queue { - lane: std::sync::Mutex, - cv: std::sync::Condvar, - ahead: usize, -} - -struct Lane { - items: Vec>, - /// The next photo to start. - next: usize, - /// Photos handed to the placer so far. - placed: usize, - stop: bool, - done: std::collections::HashMap>, -} - -impl Lane { - fn take_next(&mut self) -> Option<(usize, PreparedExport)> { - let i = self.next; - let item = self.items.get_mut(i)?.take()?; - self.next += 1; - Some((i, item)) - } -} - -impl Queue { - fn lock(&self) -> std::sync::MutexGuard<'_, Lane> { - self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> { - self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn finish(&self, i: usize, r: Result) { - self.lock().done.insert(i, r); - self.cv.notify_all(); - } - - /// A lane thread: run photos until none is left or the batch stops. - fn work(&self) { - loop { - let mut l = self.lock(); - let (i, item) = loop { - if l.stop || l.next >= l.items.len() { - return; - } - if l.next < l.placed.saturating_add(self.ahead) { - match l.take_next() { - Some(t) => break t, - None => return, - } - } - l = self.wait(l); - }; - drop(l); - self.finish(i, run_gated(item)); - } - } - - /// Photo `i`'s result, waiting for it; run here when no lane has started it yet. - fn result(&self, i: usize) -> Result { - let mut l = self.lock(); - loop { - if let Some(r) = l.done.remove(&i) { - l.placed = i + 1; - drop(l); - self.cv.notify_all(); - return r; - } - if l.next == i { - let Some((_, item)) = l.take_next() else { - return Err("export: photo missing from the batch".into()); - }; - drop(l); - self.finish(i, run_gated(item)); - l = self.lock(); - continue; - } - l = self.wait(l); - } - } - - fn stop(&self) { - self.lock().stop = true; - self.cv.notify_all(); - } -} - -/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, -/// sidecars, never an original) and its write, in batch order. -struct Placer<'a> { - o: &'a ExportOptions, - to: &'a Destination, - single: bool, - taken: std::collections::HashSet, - out: Vec, - write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &'a dyn Fn(&str) -> bool, - stop_on_error: bool, -} - -fn join_path(a: &str, b: &str) -> String { - if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) } -} - -impl<'a> Placer<'a> { - fn new( - o: &'a ExportOptions, - to: &'a Destination, - write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &'a dyn Fn(&str) -> bool, - stop_on_error: bool, - ) -> Placer<'a> { - Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } - } - - /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or - /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no - /// folder of its own, or whose folder is gone: never a file written somewhere else instead. - fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result { - let base = match self.o.destination_mode { - DestinationMode::SpecificFolder => self.to.dir.clone(), - DestinationMode::SameAsOriginal => { - let Some(d) = source_dir else { - return Err(format!( - "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" - )); - }; - if !(self.exists)(d) { - return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); - } - d.to_string() - } - }; - Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) - } - - /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it - /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. - fn place( - &mut self, - photo: lightcraft_catalog::PhotoId, - name: String, - source_dir: Option<&str>, - guard: &crate::originals::OriginalGuard, - e: Result, - ) -> Result<(), String> { - use serde_json::json; - // an exact file path needs no folder - let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; - let (o, exists) = (self.o, self.exists); - let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { - Ok(v) => v, - Err(err) if self.stop_on_error => return Err(err), - Err(err) => { - self.out.push(json!({"photo": photo.0, "file": name, "error": err})); - return Ok(()); - } - }; - let dir = dir.as_str(); - // the exported file and its sidecars - let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::>(); - let path = match self.to.exact.as_deref().filter(|_| self.single) { - Some(p) => Ok(p.to_string()), - None => { - let path = join_path(dir, &e.file_name); - let taken = &self.taken; - let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p)); - if !busy(&path) { - Ok(path) - } else { - match o.conflict { - Conflict::Overwrite => Ok(path), - Conflict::Skip => { - self.out.push(json!({"skipped": path})); - return Ok(()); - } - Conflict::Unique => { - let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b))); - let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); - (2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name")) - } - } - } - } - }; - let file = path.clone().unwrap_or_else(|_| name.clone()); - let write = &mut *self.write; - let written = path.and_then(|path| { - let files = group(&path); - // never over an original, whatever the conflict policy or the exact path said - for f in &files { - guard.check(std::path::Path::new(f))?; - } - write(&path, &e.bytes)?; - let mut sidecars = Vec::new(); - for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) { - write(sc, bytes)?; - sidecars.push(sc.clone()); - } - Ok((path, files, sidecars)) - }); - match written { - Ok((path, files, sidecars)) => { - self.taken.extend(files); - let mut entry = json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}); - if let Some(why) = &e.gpu_fallback { - entry["gpuFallback"] = json!(why); - } - self.out.push(entry); - } - Err(err) if self.stop_on_error => return Err(err), - Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})), - } - Ok(()) - } -} - #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn test_image() -> Rgba8 { - let mut img = Rgba8::new(96, 64); - for (i, p) in img.data.iter_mut().enumerate() { - let (x, y) = (i % 96, i / 96); - *p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255]; - } - img - } - - /// Issue #1670: removing a redundant alpha channel preserves samples and metadata. - #[test] - fn opaque_eight_bit_exports_are_rgb() { - let img = test_image(); - let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; - for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { - for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { - let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; - let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); - let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); - assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); - assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); - assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); - assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); - assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); - if format == ExportFormat::Png { - assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); - } - if format != ExportFormat::Tiff { - break; - } - } - } - } - - #[test] - fn eight_bit_exports_preserve_real_alpha() { - let mut img = test_image(); - img.data[0][3] = 128; - for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { - let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); - let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); - assert!(decoded.has_alpha, "{format:?} must retain real alpha"); - assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); - } - } - - #[test] - fn formats_have_magic() { - let img = test_image(); - for (f, magic) in - [(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")] - { - let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap(); - assert!(b.starts_with(magic), "{f:?}"); - } - } - - #[test] - fn size_limit_respected() { - let img = test_image(); - let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap(); - let kb = (full.len() / 1024 / 2).max(2) as u32; - let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap(); - assert!(b.len() <= kb as usize * 1024); - } - - #[test] - fn hdr_jpeg_size_limit_respected() { - let img = Rgba8::from_fn(256, 192, |x, y| { - let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663); - [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255] - }); - let hdr = DeepImage { - width: img.width, - height: img.height, - space: OutputSpace::Srgb, - samples: DeepSamples::F32( - img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(), - ), - }; - let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; - let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); - let kb = (full.len() / 2048) as u32; - let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap(); - assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len()); - let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap(); - assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting"); - let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None); - assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB"))); - } - - #[test] - fn graphic_watermark_honours_exif_orientation() { - let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); - for orientation in [2, 6] { - let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() }; - let exif = lightcraft_meta::write_exif(&meta); - let bytes = lightcraft_codecs::encode_jpeg( - &EncodeImage::rgba8(&logo), - 100, - ChromaSubsampling::S444, - &EncodeMeta { exif: Some(&exif), ..Default::default() }, - ) - .unwrap(); - let path = dir.join(format!("oriented-{orientation}.jpg")); - std::fs::write(&path, bytes).unwrap(); - let wm = Watermark { - image: path.to_string_lossy().into(), - image_width: if orientation == 6 { 0.25 } else { 0.5 }, - opacity: 1.0, - inset_x: 0.0, - inset_y: 0.0, - ..Default::default() - }; - let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - if orientation == 6 { - assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue"); - } else { - assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red"); - } - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() { - let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("tall.png"); - let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); - std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); - for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] { - let wm = Watermark { - image: path.to_string_lossy().into(), - image_width: 0.5, - opacity: 1.0, - inset_x: 0.0, - inset_y: 0.0, - rotation, - ..Default::default() - }; - let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); - let bounds = ( - lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1, - lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1, - ); - assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn graphic_watermark_fit_preserves_anchor_insets() { - let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("tall.png"); - let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); - std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); - use Anchor::*; - for (anchor, rotation, expected) in [ - (BottomRight, 90, (0, 44, 111, 71)), - (TopLeft, 270, (8, 8, 119, 35)), - (Right, 90, (0, 26, 111, 53)), - (BottomRight, 0, (94, 0, 111, 71)), - (TopLeft, 180, (8, 8, 25, 79)), - (Bottom, 0, (51, 0, 68, 71)), - // Centered axes ignore their inset rather than shrinking the image unnecessarily. - (Center, 90, (0, 25, 119, 54)), - (Top, 90, (0, 8, 119, 37)), - ] { - let wm = Watermark { - image: path.to_string_lossy().into(), - image_width: 0.5, - opacity: 1.0, - inset_x: 0.1, - inset_y: 0.1, - rotation, - anchor, - ..Default::default() - }; - let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); - let bounds = ( - lit.iter().map(|p| p.0).min().unwrap(), - lit.iter().map(|p| p.1).min().unwrap(), - lit.iter().map(|p| p.0).max().unwrap(), - lit.iter().map(|p| p.1).max().unwrap(), - ); - assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn sharpen_increases_edge_contrast() { - let mut img = Rgba8::new(8, 8); - for (i, p) in img.data.iter_mut().enumerate() { - let v = if i % 8 < 4 { 80 } else { 170 }; - *p = [v, v, v, 255]; - } - output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High); - assert!(img.data[3][0] < 80 && img.data[4][0] > 170); - } - - /// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` - /// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of - /// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. - #[test] - fn exported_keywords_follow_their_options() { - use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo}; - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); - c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); - let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); - p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()]; - let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap(); - assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]); - assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]); - let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap(); - let back = lightcraft_meta::extract(&jpg); - assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone())); - } - - #[test] - fn metadata_policies() { - use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; - let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); - p.meta.camera = "Synthetic X2".into(); - p.meta.copyright = "(c) Me".into(); - p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted; - p.meta.usage_terms = "Editorial use only".into(); - p.meta.copyright_url = "https://example.com/rights".into(); - p.meta.shutter = "1/250".into(); - p.meta.gps = Some((43.0, -110.0)); - let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap(); - assert_eq!(all.model.as_deref(), Some("Synthetic X2")); - assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9); - assert!(all.gps.is_some()); - let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() }; - let m = export_metadata(&p, &Catalog::new(), &o).unwrap(); - assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); - let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); - assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); - // "copyright only" keeps all the copyright info: status, usage terms, info URL - assert_eq!( - (c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()), - (Some(true), Some("Editorial use only"), Some("https://example.com/rights")) - ); - assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); - // embedded and readable back from the JPEG - let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); - let back = lightcraft_meta::extract(&jpg); - assert_eq!(back.model.as_deref(), Some("Synthetic X2")); - assert_eq!(back.copyright.as_deref(), Some("(c) Me")); - assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only"))); - assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights")); - assert!(back.gps.is_some()); - } - - #[test] - fn watermark_draws_in_the_anchored_corner_only() { - let mut img = Rgba8::new(400, 300); - for p in img.data.iter_mut() { - *p = [0, 0, 0, 255]; - } - let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}})); - let wm = o.watermark.clone().unwrap(); - draw_watermark(&mut img, &wm); - let lit = |x0: usize, x1: usize, y0: usize, y1: usize| { - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).count() - }; - assert!(lit(200, 400, 225, 300) > 100, "bottom-right has text"); - assert!(lit(0, 200, 0, 150) == 0, "top-left untouched"); - // string shorthand - assert_eq!(ExportOptions::from_json(&serde_json::json!({"watermark": "© Me"})).watermark.unwrap().text, "© Me"); - assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); - } - - /// Horizontal and vertical margins move a text watermark separately; settings saved with one - /// `inset` (before the two margins) read as both, and either new key overrides it. - #[test] - fn watermark_insets_are_horizontal_and_vertical() { - // gaps between the drawn text and the right and bottom edges of a 400 × 300 image - let gaps = |wm: &Watermark| { - let mut img = Rgba8::new(400, 300); - draw_watermark(&mut img, wm); - let lit: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); - let right = lit.iter().map(|p| p.0).max().unwrap(); - let bottom = lit.iter().map(|p| p.1).max().unwrap(); - (399 - right as i64, 299 - bottom as i64) - }; - let base = Watermark { text: "LC".into(), size: 0.1, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; - let (r0, b0) = gaps(&base); - // 0.2 and 0.1 of the 300 px short edge - let (r1, b1) = gaps(&Watermark { inset_x: 0.2, ..base.clone() }); - assert!((r1 - r0 - 60).abs() <= 1 && (b1 - b0).abs() <= 1, "horizontal: {r0},{b0} → {r1},{b1}"); - let (r2, b2) = gaps(&Watermark { inset_y: 0.1, ..base.clone() }); - assert!((r2 - r0).abs() <= 1 && (b2 - b0 - 30).abs() <= 1, "vertical: {r0},{b0} → {r2},{b2}"); - // the old single margin - let wm = |w: serde_json::Value| ExportOptions::from_json(&json!({"watermark": w})).watermark.unwrap(); - let old = wm(json!({"text": "LC", "inset": 0.1})); - assert_eq!((old.inset_x, old.inset_y), (0.1, 0.1)); - let mixed = wm(json!({"text": "LC", "inset": 0.1, "insetY": 0.3})); - assert_eq!((mixed.inset_x, mixed.inset_y), (0.1, 0.3)); - assert!(ExportOptions::from_params(&json!({"watermark": {"text": "LC", "inset": 0.1}})).is_ok()); - // saved and read back as the two margins - let o = ExportOptions { watermark: Some(Watermark { inset_x: 0.05, inset_y: 0.15, ..base }), ..Default::default() }; - let saved = o.to_json(); - assert!(saved["watermark"].get("inset").is_none(), "{saved}"); - assert_eq!(ExportOptions::from_json(&saved).watermark.map(|w| (w.inset_x, w.inset_y)), Some((0.05, 0.15))); - } - - /// A turned text watermark is placed by its turned box: taller than wide at 90° and 270°, the - /// same corner and insets, and an unknown angle draws it unturned. - #[test] - fn watermark_rotation_turns_the_text_in_its_corner() { - let lit = |wm: &Watermark| { - let mut img = Rgba8::new(400, 300); - draw_watermark(&mut img, wm); - let on: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); - let (x0, x1) = (on.iter().map(|p| p.0).min().unwrap(), on.iter().map(|p| p.0).max().unwrap()); - let (y0, y1) = (on.iter().map(|p| p.1).min().unwrap(), on.iter().map(|p| p.1).max().unwrap()); - (x0, x1, y0, y1, img) - }; - let base = Watermark { text: "LightCraft".into(), size: 0.08, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; - let (a0, a1, b0, b1, flat) = lit(&base); - assert!(a1 - a0 > 2 * (b1 - b0), "unturned text is wide"); - for rotation in [90, 270] { - let (x0, x1, y0, y1, _) = lit(&Watermark { rotation, ..base.clone() }); - assert!(y1 - y0 > 2 * (x1 - x0), "{rotation}°: tall"); - assert!(x1 >= 390 && y1 >= 290, "{rotation}°: still in the bottom-right corner ({x1}, {y1})"); - // 0.1 × 300 px further from the right edge - let (_, moved, ..) = lit(&Watermark { rotation, inset_x: 0.1, ..base.clone() }); - assert!((x1 as i64 - moved as i64 - 30).abs() <= 1, "{rotation}°: {x1} → {moved}"); - } - let (x0, x1, y0, y1, _) = lit(&Watermark { rotation: 180, ..base.clone() }); - assert!(((x1 - x0) as i64 - (a1 - a0) as i64).abs() <= 2 && ((y1 - y0) as i64 - (b1 - b0) as i64).abs() <= 2, "180°: same size"); - assert!(x1 >= 390 && y1 >= 290, "180°: same corner"); - let (.., odd) = lit(&Watermark { rotation: 45, ..base.clone() }); - assert_eq!(odd.data, flat.data, "an angle that isn't a quarter turn draws unturned"); - // saved and read back; older settings have none - let o = ExportOptions { watermark: Some(Watermark { rotation: 270, ..base }), ..Default::default() }; - assert_eq!(ExportOptions::from_json(&o.to_json()).watermark.map(|w| w.rotation), Some(270)); - assert_eq!(ExportOptions::from_json(&json!({"watermark": {"text": "x"}})).watermark.map(|w| w.rotation), Some(0)); - } - - /// A preview keeps the export's 6 px minimum text size as a share of the photo: a watermark - /// clamped to 6 px in a 150 px export covers the same share of a 600 px preview. - #[test] - fn watermark_preview_scales_the_minimum_text_size() { - let wm = Watermark { text: "LightCraft".into(), size: 0.01, opacity: 1.0, shadow: false, ..Default::default() }; - let rows = |img: &Rgba8| (0..img.height).filter(|&y| (0..img.width).any(|x| img.get(x, y)[0] > 64)).count() as f32; - let mut export = Rgba8::new(200, 150); - draw_watermark(&mut export, &wm); - let mut preview = Rgba8::new(800, 600); - draw_watermark_preview(&mut preview, &wm, 150); - let mut unscaled = Rgba8::new(800, 600); - draw_watermark(&mut unscaled, &wm); - let (export_share, preview_share) = (rows(&export) / 150.0, rows(&preview) / 600.0); - assert!((preview_share - export_share).abs() < 0.02, "export {export_share}, preview {preview_share}"); - assert!(rows(&unscaled) < rows(&preview) / 2.0, "the unscaled 6 px floor would draw it too small"); - // drawn at the export's own size, the preview is the export - let mut same = Rgba8::new(200, 150); - draw_watermark_preview(&mut same, &wm, 150); - assert_eq!(same.data, export.data); - } - - fn vertical_japanese() -> Watermark { - let options = - ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); - options.watermark.unwrap() - } - - #[test] - fn japanese_watermark_options_and_legacy_defaults() { - let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); - assert!(!old.watermark.unwrap().vertical); - let wm = vertical_japanese(); - assert!(wm.vertical); - let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap(); - assert!(round.vertical); - } - - /// Built without craft-fonts, a Japanese watermark still lays out and draws (Inter's - /// missing-glyph boxes), and Latin text is unaffected. - #[test] - fn watermarks_work_without_craft_fonts() { - assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); - for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { - let mut covered = 0usize; - watermark_coverage(400, 300, &wm, &[], 1.0, |_, _, k, _| covered += usize::from(k > 0.0)); - assert!(covered > 0, "{:?} draws something", wm.text); - } - } - - #[test] - fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { - use ab_glyph::Font; - let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { - lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); - return; - }; - let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); - // BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。. - assert_eq!(font.glyph_id('︑').0, 0); - assert_eq!(font.glyph_id('︒').0, 0); - let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap()); - for ch in ['、', '。'] { - let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0); - assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}"); - let rect = font.outline_glyph(glyph).unwrap().px_bounds(); - assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}"); - } - let craft = Box::leak( - vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(), - ); - let wm = Watermark { - text: "、。".into(), - vertical: true, - size: 0.1, - anchor: Anchor::TopLeft, - inset_x: 0.0, - inset_y: 0.0, - shadow: false, - opacity: 1.0, - ..Default::default() - }; - let mut pixels = Vec::new(); - watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| { - if k > 0.5 { - pixels.push((x, y)); - } - }); - assert!(!pixels.is_empty()); - for (x, y) in pixels { - assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}"); - } - } - - #[test] - fn vertical_watermarks_keep_combining_marks_in_one_cell() { - let coverage = |text: &str, craft| { - let wm = Watermark { - text: text.into(), - vertical: true, - size: 0.1, - anchor: Anchor::Center, - inset_x: 0.0, - inset_y: 0.0, - shadow: false, - opacity: 1.0, - ..Default::default() - }; - let mut pixels = vec![0.0f32; 400 * 300]; - watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| pixels[y * 400 + x] = k); - assert!(pixels.iter().any(|&k| k > 0.0)); - pixels - }; - let compare = |a: &str, b: &str, craft| { - let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft)); - let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b); - assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}"); - }; - for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] { - compare(composed, decomposed, &[]); - } - if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") { - for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] { - compare(composed, decomposed, crate::fonts::CRAFT_FONTS); - } - } else { - lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); - } - } - - #[test] - fn upright_watermark_cell_retains_multiple_glyphs() { - use ab_glyph::{Font, ScaleFont}; - let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap(); - let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap()); - // There is no precomposed A with a combining long solidus overlay. - let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap(); - assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph"); - let baseline = font.as_scaled(30.0).ascent(); - for glyph in glyphs { - assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark"); - assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink"); - } - } - - #[test] - fn japanese_watermarks_support_vertical_columns() { - use ab_glyph::Font; - let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); - if fonts.len() < 2 { - lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); - return; - } - for c in "日本語の文字".chars() { - assert!(fonts[1..].iter().any(|f| f.glyph_id(c).0 != 0), "a craft-fonts face has {c}"); - } - let wm = vertical_japanese(); - let mut img = Rgba8::new(400, 300); - img.data.fill([0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect(); - assert!(lit.len() > 100); - let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); - let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); - assert!(h > w * 2, "vertical Japanese must extend down the column"); - let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset_x: 0.0, inset_y: 0.0, ..wm }; - img.data.fill([0, 0, 0, 255]); - draw_watermark(&mut img, &two); - for (text, dx) in [("本", 0), ("日", 30)] { - let mut single = Rgba8::new(400, 300); - single.data.fill([0, 0, 0, 255]); - draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() }); - for y in 0..30 { - for x in 0..30 { - assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left"); - } - } - } - } - - #[test] - fn graphic_watermark_is_placed_scaled_and_blended() { - let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let logo_path = dir.join("logo.png"); - // 40×20: left half opaque red, right half fully transparent - let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] }); - let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap(); - std::fs::write(&logo_path, png).unwrap(); - let mut img = Rgba8::new(400, 300); - img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - let wm = Watermark { - image: logo_path.to_string_lossy().into(), - image_width: 0.25, - opacity: 1.0, - inset_x: 0.0, - inset_y: 0.0, - ..Default::default() - }; - draw_watermark(&mut img, &wm); - // 100 px wide × 50 px high in the bottom-right corner - assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); - assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo"); - assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo"); - assert_eq!(img.get(10, 10), [0, 0, 0, 255]); - // 30 px from the right edge and 15 px from the bottom: x 270..370, y 235..285 - let mut img = Rgba8::new(400, 300); - img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - draw_watermark(&mut img, &Watermark { inset_x: 0.1, inset_y: 0.05, ..wm.clone() }); - assert!(img.get(272, 283)[0] > 200, "inside the moved logo: {:?}", img.get(272, 283)); - assert_eq!(img.get(267, 283), [0, 0, 0, 255], "left of the logo"); - assert_eq!(img.get(272, 288), [0, 0, 0, 255], "below the logo"); - // turned: 180° puts the opaque half on the right; 90° clockwise puts it on top of a 50 × 100 box - let turned = |rotation: u16| { - let mut img = Rgba8::new(400, 300); - img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - draw_watermark(&mut img, &Watermark { rotation, ..wm.clone() }); - img - }; - let half = turned(180); - assert!(half.get(390, 280)[0] > 200 && half.get(310, 280) == [0, 0, 0, 255]); - let quarter = turned(90); - assert!(quarter.get(375, 220)[0] > 200, "top half of the turned logo: {:?}", quarter.get(375, 220)); - assert_eq!(quarter.get(375, 280), [0, 0, 0, 255], "its transparent half below"); - assert_eq!(quarter.get(340, 220), [0, 0, 0, 255], "left of the 50 px wide box"); - assert!(quarter.get(352, 202)[0] > 200 && quarter.get(398, 248)[0] > 200, "the whole box is covered"); - // three quarter turns: the opaque half at the bottom - let three = turned(270); - assert!(three.get(375, 280)[0] > 200 && three.get(375, 220) == [0, 0, 0, 255]); - // an image-only watermark survives the params (no text needed) - let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}})); - assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn naming_and_params() { - let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); - assert_eq!(o.format, ExportFormat::Jpeg); - assert_eq!(o.quality, 100); - assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) })); - assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg"); - assert_eq!(o.ppi, 240); - let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() }; - assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF"); - assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001"); - assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng"); - } - - fn named(file_name: &str) -> lightcraft_catalog::Photo { - use lightcraft_catalog::{Photo, PhotoId, Source}; - Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00") - } - - /// Export naming takes the same tokens as Rename Photos and import renaming. - #[test] - fn naming_uses_the_rename_tokens() { - let mut p = named("DSC_0815.NEF"); - p.captured = Some("2026-09-30T14:05:09".into()); - p.meta.camera = "Model X/2".into(); - p.meta.title = "Harbour".into(); - p.rating = 5; - let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() }; - assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg"); - assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _"); - assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits"); - assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg"); - // nothing left after expanding: the original name - p.meta.title.clear(); - assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg"); - } - - #[test] - fn params_round_trip() { - let o = ExportOptions { - format: ExportFormat::Tiff, - quality: 70, - resize: Some(Resize { mode: ResizeMode::Megapixels, value: 12.5, height: 0, dont_enlarge: false }), - ppi: 300, - limit_kb: Some(500), - sharpen: SharpenFor::Glossy, - naming: "{date}-{name}-{seq}".into(), - start_number: 42, - subfolder: "Client".into(), - conflict: Conflict::Skip, - tiff_compression: TiffCompression::Lzw, - metadata: MetadataPolicy::Copyright, - remove_location: true, - watermark: Some(Watermark { text: "© LC".into(), ..Default::default() }), - color_space: OutputSpace::ProPhoto, - bit_depth: Some(16), - ..Default::default() - }; - assert_eq!(ExportOptions::from_json(&o.to_json()), o); - let full = ExportOptions::default(); - assert_eq!(ExportOptions::from_json(&full.to_json()), full); - assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit"); - let mut p = named("IMG.png"); - p.captured = Some("2026-09-30T10:00:00".into()); - assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif"); - } - - #[test] - fn resize_modes() { - let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)); - // a 6000 × 4000 landscape and a 4000 × 6000 portrait - assert_eq!(size(json!({"longEdge": 1500}), 6000.0, 4000.0), Some((1500, 1000))); - assert_eq!(size(json!({"longEdge": 1500}), 4000.0, 6000.0), Some((1000, 1500))); - assert_eq!(size(json!({"shortEdge": 1080}), 6000.0, 4000.0), Some((1620, 1080))); - assert_eq!(size(json!({"width": 3000}), 4000.0, 6000.0), Some((3000, 4500))); - assert_eq!(size(json!({"height": 1000}), 6000.0, 4000.0), Some((1500, 1000))); - // W × H fits either orientation: the long edge gets the larger number - assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667))); - assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000))); - assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500))); - assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225))); - // Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620) - assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180))); - assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337))); - assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160))); - // `fit` only matters with both numbers - assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667))); - let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap(); - assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}"); - assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000))); - // don't enlarge (default) vs enlarge - assert_eq!(size(json!({"longEdge": 8000}), 6000.0, 4000.0), Some((6000, 4000))); - assert_eq!(size(json!({"longEdge": 9000, "dontEnlarge": false}), 6000.0, 4000.0), Some((9000, 6000))); - // full size / absent - assert_eq!(size(json!({"longEdge": 0}), 6000.0, 4000.0), None); - assert_eq!(size(json!({}), 6000.0, 4000.0), None); - assert!(ExportOptions::has_size_param(&json!({"longEdge": 0})) && !ExportOptions::has_size_param(&json!({"quality": 3}))); - // the `resize` object form round-trips (what the dialog and prefs store) - let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false }; - let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize; - assert_eq!(back, Some(r)); - let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true }; - assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r)); - assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None); - assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300); - // never larger than the encoders' limit, never empty - assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most"); - assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge"); - assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1)); - } - - /// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the - /// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the - /// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos - /// larger than that are untouched. - #[test] - fn enlargements_stop_at_512_megapixels() { - let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap(); - for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] { - let (w, h) = size(p.clone(), 6000.0, 4000.0); - assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}"); - assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape"); - } - // below the ceiling an enlargement is as asked - assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000)); - // a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged - assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); - assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000)); - assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); - } - - /// An enlarged export that needs more memory than the computer has is refused with a message, - /// before it starts; everything else goes ahead. - #[test] - fn enlarged_exports_must_fit_the_computers_memory() { - let gb = 1usize << 30; - let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap(); - assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}"); - assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits"); - assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused"); - assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export"); - assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory"); - } - - #[test] - fn resize_size_survives_the_render() { - // the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312) - let cropped = |r: Resize, w: u32, h: u32, x1: f64| { - let mut p = named("IMG.ARW"); - (p.width, p.height) = (w, h); - std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0); - let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() }); - lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh)) - }; - let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0); - let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true }; - // a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5 - let hair = 1.0 - 1e-9; - assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333)); - assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365)); - assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333)); - assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); - assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); - assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000)); - // 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says - for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] { - for (w, h) in [(w, h), (h, w)] { - for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] { - for r in [ - Resize::long_edge(value as u32), - mode(ResizeMode::ShortEdge, value, 0), - mode(ResizeMode::Width, value, 0), - mode(ResizeMode::Height, value, 0), - mode(ResizeMode::Dimensions, value, 1365), - mode(ResizeMode::WidthHeight, value, 1365), - mode(ResizeMode::Megapixels, value / 1000.0, 0), - mode(ResizeMode::Percent, value / 100.0, 0), - ] { - let want = r.apply(w.into(), h.into()); - assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}"); - } - } - } - } - } - - /// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale` - /// clamp with its lower bound above its upper one, which panics, in the export and in the - /// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a - /// pixel. - #[test] - fn resize_survives_photos_too_long_for_the_encoders() { - let modes = [ - json!({"longEdge": 2000}), - json!({"shortEdge": 1}), - json!({"width": 500, "height": 500}), - json!({"megapixels": 1}), - json!({"percent": 50}), - json!({"percent": 1000, "dontEnlarge": false}), - ]; - for p in modes { - for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] { - let r = ExportOptions::from_json(&p).resize.unwrap(); - let (ow, oh) = r.apply(w, h); - assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}"); - assert!(r.scale(w, h).is_finite()); - } - } - // (a short edge under a pixel would be scaled up to one, which the limit then caps) - assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1)); - assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535)); - } - - /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. - fn p3_green(space: OutputSpace) -> Rgba8 { - use lightcraft_color::{DISPLAY_P3, REC2020}; - let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]); - let src = lightcraft_raster::Rgb32f::filled(16, 16, c); - let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) }; - lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image - } - - /// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the - /// recognised space of the embedded profile. - fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option) { - let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); - let m = d.space.to_space(&lightcraft_color::SRGB); - (m.apply_f32(d.image.get(8, 8)), d.space.named) - } - - #[test] - fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() { - let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; - let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap()); - assert_eq!(named, Some(NamedSpace::DisplayP3)); - assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}"); - let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap()); - assert_eq!(named, Some(NamedSpace::Srgb)); - assert!(s.iter().all(|v| *v > -0.002), "{s:?}"); - // the green itself is about the same brightness either way - assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}"); - } - - #[test] - fn every_space_embeds_its_own_profile_and_round_trips() { - let grey = { - let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]); - let base = lightcraft_pipeline::RenderRequest::fit(16, 16); - move |space| { - lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }) - .image - } - }; - let reference = - decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0; - for space in OutputSpace::ALL { - for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { - let o = ExportOptions { format, color_space: space, ..Default::default() }; - let bytes = encode_image(&grey(space), &o).unwrap(); - let (px, named) = decoded_in_srgb(&bytes); - assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}"); - // a neutral grey decodes to the same linear value whatever the output space - for c in 0..3 { - assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}"); - } - } - let icc = icc::write_named(named_space(space)); - let info = icc::parse(&icc).expect("our profile parses"); - assert_eq!(info.named, Some(named_space(space))); - assert!(info.description.as_deref().is_some_and(|d| !d.is_empty())); - } - let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() }; - assert_eq!(o.effective_space(), OutputSpace::Srgb); - let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"})); - assert_eq!(o.color_space, OutputSpace::DisplayP3); - assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb); - } - - #[test] - fn srgb_watermark_colour_is_converted() { - assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]); - for s in OutputSpace::ALL { - assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}"); - assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}"); - } - let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); - assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); - } - - /// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`. - fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered { - let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]); - let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) }; - lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) - } - - fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) { - let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); - let mut v: Vec = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect(); - v.dedup(); - (v.len(), d) - } - - #[test] - fn sixteen_bit_tiff_and_png_have_no_banding() { - let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; - assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit"); - let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb); - assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); - let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap()); - let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb); - let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap()); - eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}"); - assert!(levels8 < 40, "{levels8}"); - assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}"); - assert_eq!(d16.space.named, Some(NamedSpace::Srgb)); - // the 8-bit preview of a deep render matches the 8-bit render - for (a, b) in r16.image.data.iter().zip(&r8.image.data) { - assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}"); - } - let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() }; - let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3); - let bytes = encode_rendered(&rp, &png, None).unwrap(); - assert_eq!(bytes[24], 16, "PNG IHDR bit depth"); - let (levels, d) = distinct_levels(&bytes); - assert!(levels > 10 * levels8, "{levels}"); - assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); - // sharpening and watermarking work on deep images too - let o = ExportOptions { - sharpen: SharpenFor::Matte, - watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), - ..tiff(None) - }; - assert!(encode_rendered(&r16, &o, None).is_ok()); - } - - #[test] - fn float_tiff_is_linear_with_a_linear_profile() { - let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() }; - assert_eq!(o.effective_depth(), OutputDepth::F32Linear); - let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto); - let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap()); - assert!(levels > 500, "{levels}"); - assert_eq!(df.space.named, Some(NamedSpace::ProPhoto)); - assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc); - // decodes to the same light as the 16-bit gamma-encoded export - let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() }; - let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap()); - for x in [0, 300, 700, 1023] { - let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]); - assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}"); - } - } - - #[test] - fn jxl_options() { - assert_eq!(ExportFormat::parse("JPEG-XL"), Some(ExportFormat::Jxl)); - let o = ExportOptions::from_params(&json!({"format": "jxl", "jxlLossless": true, "jxlEffort": "fast", "jxlProgressive": true})).unwrap(); - assert_eq!((o.format, o.jxl_lossless, o.jxl_effort, o.jxl_progressive), (ExportFormat::Jxl, true, JxlEffort::Fast, true)); - assert_eq!(ExportOptions::from_json(&o.to_json()), o, "saved presets keep the JPEG XL settings"); - assert!(ExportOptions::from_params(&json!({"jxlEffort": "best"})).is_err()); - assert!(ExportOptions::from_params(&json!({"jxlLossless": "yes"})).is_err()); - let space = |lossless, color_space, hdr| { - ExportOptions { format: ExportFormat::Jxl, jxl_lossless: lossless, color_space, hdr, ..Default::default() }.effective_space() - }; - assert_eq!(space(false, OutputSpace::DisplayP3, false), OutputSpace::DisplayP3); - assert_eq!(space(false, OutputSpace::ProPhoto, false), OutputSpace::Rec2020, "lossy has no ProPhoto enum"); - assert_eq!(space(true, OutputSpace::ProPhoto, false), OutputSpace::ProPhoto, "lossless keeps the profile"); - assert_eq!(space(true, OutputSpace::Srgb, true), OutputSpace::Rec2020); - let o = ExportOptions { format: ExportFormat::Jxl, hdr: true, ..Default::default() }; - assert!(o.hdr_output()); - assert_eq!(o.effective_depth(), OutputDepth::F32Hdr); - assert_eq!(ExportFormat::Jxl.extension(), "jxl"); - } - - #[test] - fn jxl_every_space_round_trips() { - // wide-gamut green stays outside sRGB in P3, Rec. 2020 and (lossless) ProPhoto files - for (lossless, space) in [ - (false, OutputSpace::Srgb), - (false, OutputSpace::DisplayP3), - (false, OutputSpace::Rec2020), - (true, OutputSpace::DisplayP3), - (true, OutputSpace::AdobeRgb), - (true, OutputSpace::ProPhoto), - ] { - let o = ExportOptions { format: ExportFormat::Jxl, color_space: space, jxl_lossless: lossless, quality: 95, ..Default::default() }; - let png = ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; - let img = p3_green(space); - let bytes = encode_image(&img, &o).unwrap(); - assert_eq!(lightcraft_codecs::sniff(&bytes), Some(lightcraft_codecs::Format::Jxl)); - let (got, _) = decoded_in_srgb(&bytes); - let (want, _) = decoded_in_srgb(&encode_image(&img, &png).unwrap()); - for c in 0..3 { - assert!((got[c] - want[c]).abs() < 0.01, "{space:?} lossless={lossless}: {got:?} vs PNG {want:?}"); - } - if space != OutputSpace::Srgb { - assert!(got[0] < -0.03 || got[2] < -0.03, "{space:?}: outside sRGB after the round trip: {got:?}"); - } - } - } - - #[test] - fn jxl_sixteen_bit() { - let o = ExportOptions { - format: ExportFormat::Jxl, - bit_depth: Some(16), - jxl_lossless: true, - color_space: OutputSpace::DisplayP3, - ..Default::default() - }; - assert_eq!(o.effective_depth(), OutputDepth::U16); - let (levels8, _) = distinct_levels( - &encode_rendered(&ramp(OutputDepth::U8, OutputSpace::DisplayP3), &ExportOptions { bit_depth: Some(8), ..o.clone() }, None).unwrap(), - ); - let (levels, d) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &o, None).unwrap()); - assert_eq!(d.bit_depth, 16); - assert!(levels > 10 * levels8, "{levels} vs {levels8}"); - // lossy 16-bit encodes too - let lossy = ExportOptions { jxl_lossless: false, ..o }; - assert!(encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &lossy, None).is_ok()); - } - - #[test] - fn bit_depth_options_per_format() { - let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth(); - assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8); - assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8); - assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8); - assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16); - assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16); - assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16)); - assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None); - assert_eq!(d(ExportFormat::Jxl, Some(16)), OutputDepth::U16); - assert_eq!(d(ExportFormat::Jxl, None), OutputDepth::U8); - for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif, ExportFormat::Jxl] { - let first = ExportOptions::bit_depths(f)[0].0; - let def = d(f, None); - assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default"); - } - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn avif_ten_bit() { - let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() }; - let r = ramp(OutputDepth::U16, OutputSpace::Srgb); - match encode_rendered(&r, &o, None) { - Ok(b) => assert_eq!(&b[4..8], b"ftyp"), - Err(e) => assert!(e.contains("not available"), "{e}"), - } - } - - /// Issue #181: a command about to export refuses what `from_json` would silently default — - /// unknown keys (naming the nearest known one) and values of the wrong kind or range. - #[test] - fn export_params_are_validated() { - 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})); - assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); - assert!(err(json!({"qualty": 5})).contains("did you mean `quality`")); - assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number")); - assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100")); - assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer")); - assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer")); - assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy")); - assert!(err(json!({"format": "bmp"})).contains("`format` must be one of")); - assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32")); - assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of")); - assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false")); - assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight")); - assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`")); - assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:")); - assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids")); - assert!(err(json!("jpeg")).contains("params must be an object")); - assert!( - ExportOptions::validate("export.savePreset", &json!({"qualty": 5})) - .unwrap_err() - .to_string() - .starts_with("invalid parameters for `export.savePreset`") - ); - // 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)); - } - let full = ExportOptions { - resize: Some(Resize::default()), - limit_kb: Some(500), - watermark: Some(Watermark { text: "©".into(), ..Default::default() }), - format: ExportFormat::Png, - bit_depth: Some(16), - ..Default::default() - }; - let mut j = full.to_json(); - for (k, v) in [ - ("dir", json!("/tmp/out")), - ("background", json!(true)), - ("preset", json!("x")), - ("ids", json!([1, 2])), - ("id", json!(1)), - ("path", json!("a.jpg")), - ] { - j[k] = v; - } - assert_eq!(ExportOptions::from_params(&j).unwrap(), full); - // Add to Apple Photos (issue #236): a switch and an album name; on only where Photos is - assert!(err(json!({"addToPhotos": "yes"})).contains("`addToPhotos` must be true or false")); - assert!(err(json!({"photosAlbum": 3})).contains("`photosAlbum` must be a string")); - assert!(err(json!({"photosAlbum": "a\0b"})).contains("NUL")); - assert!(err(json!({"photosAlbm": "x"})).contains("did you mean `photosAlbum`")); - let album = ExportOptions { photos_album: "Trip “2026” \\ \"x\"".into(), ..Default::default() }; - assert_eq!(ExportOptions::from_params(&album.to_json()).unwrap(), album); - let on = ExportOptions { add_to_photos: true, ..album }; - if cfg!(target_os = "macos") { - assert_eq!(ExportOptions::from_params(&on.to_json()).unwrap(), on); - } else { - assert!(err(on.to_json()).contains("Apple Photos is only available on macOS")); - // stored settings (a preset or Export with Previous made on a Mac) still read - assert!(ExportOptions::from_json(&on.to_json()).add_to_photos); - } - assert_eq!(ExportOptions::from_json(&json!({"photosAlbum": " Trip "})).photos_album, "Trip"); - assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default()); - // null is "not given", as a dropped key is - ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); - } - - /// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers - /// it), not written at the format's own depth; the error names the depths the format takes. - #[test] - fn bit_depth_must_suit_the_format() { - use serde_json::json; - let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); - assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg")); - assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png")); - assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff")); - // no `format`: the one `path` names, else the default (JPEG) - assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png")); - assert!(err(json!({"bitDepth": 16})).contains("for jpeg")); - assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok()); - // a path naming no format, or a `null` format, exports the default too - assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg")); - assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg")); - assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg")); - // or the caller's own format for such a path, which is the one returned - let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png); - assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png")); - assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png); - assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff); - // stored settings (a preset, Export with Previous) lose such a depth instead of failing - let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16})); - assert_eq!(stored.get("bitDepth"), None); - assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32); - for f in [ - ExportFormat::Jpeg, - ExportFormat::Png, - ExportFormat::Tiff, - ExportFormat::Psd, - ExportFormat::Webp, - ExportFormat::Avif, - ExportFormat::Jxl, - ExportFormat::Original, - ExportFormat::Dng, - ExportFormat::RenderedDng, - ] { - for &(b, _) in ExportOptions::bit_depths(f) { - let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() }; - assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}"); - } - } - } - - /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own - /// folder; it is checked like the other params and kept in presets and Export with Previous. - #[test] - fn destination_mode_param() { - use serde_json::json; - assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); - for (v, want) in [ - ("sameAsOriginal", DestinationMode::SameAsOriginal), - ("SameAsOriginal", DestinationMode::SameAsOriginal), - ("specificFolder", DestinationMode::SpecificFolder), - ] { - assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); - } - let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); - assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); - assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); - assert!( - ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) - .unwrap_err() - .to_string() - .contains("did you mean `destinationMode`") - ); - // saved (a preset, `lastExport`) and read back - let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; - let j = o.to_json(); - assert_eq!(j["destinationMode"], "sameAsOriginal"); - assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); - } - - /// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the - /// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and - /// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and - /// `conflict` had the same gap. - #[test] - fn accepted_choices_are_the_ones_exported() { - use serde_json::json; - let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); - let metadata = [ - ("all", MetadataPolicy::All), - ("allExceptCamera", MetadataPolicy::AllExceptCamera), - ("copyright", MetadataPolicy::Copyright), - ("none", MetadataPolicy::None), - ]; - for (name, want) in metadata { - for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { - assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); - } - } - for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] - { - assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); - } - for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { - assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); - } - for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { - assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); - } - // an explicit value nothing reads as is an error, never the permissive default - for v in ["", " none", "nothing", "all-except-camera"] { - let e = read("metadata", v).unwrap_err(); - assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); - } - assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); - assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); - // absent (or null) keeps the defaults - let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); - assert_eq!( - (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), - (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) - ); - // `removeLocation` stays independent of the policy's spelling - let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); - assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); - // stored settings (lenient `from_json`) read the same spellings - assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); - // and `to_json` still writes what the MCP schema lists - let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); - assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); - } - - /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in - /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. - #[test] - fn watermark_params_are_validated() { - use serde_json::json; - let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); - assert!(err(json!(5)).contains( - "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}" - )); - assert!(err(json!({"text": "x", "bogus": 1})).contains( - "unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth)" - )); - assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); - for size in [json!(3), json!(0.5001), json!(0), json!("big")] { - let e = err(json!({"text": "x", "size": size})); - assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}"); - } - assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); - assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); - assert!(err(json!({"text": "x", "insetX": 0.5})).contains("`watermark.insetX` must be a number 0..0.4")); - assert!(err(json!({"text": "x", "insetY": "far"})).contains("`watermark.insetY` must be a number 0..0.4")); - for rotation in [json!(45), json!(-90), json!(360), json!("90"), json!(90.5)] { - let e = err(json!({"text": "x", "rotation": rotation})); - assert!(e.contains("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)"), "{rotation}: {e}"); - } - assert!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "rotation": 270}})).is_ok()); - assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); - assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); - assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); - assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`")); - assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false")); - assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string")); - // the documented shapes pass and read as before - let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap(); - assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me")); - let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3}); - let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap(); - assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow); - assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5)); - // the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges - assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15); - assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0); - } - - /// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there): - /// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`. - #[allow(clippy::type_complexity)] - fn lanes_batch( - s: &mut crate::Session, - ids: &[lightcraft_catalog::PhotoId], - o: &ExportOptions, - lanes: usize, - stop_on_error: bool, - cancel_at: usize, - ) -> (Result, String>, Vec<(String, Vec)>, Vec<(usize, String)>) { - let items = prepare_batch(s, ids, o).unwrap(); - let to = Destination { dir: "out".into(), exact: None }; - let mut written = Vec::new(); - let mut write = |p: &str, b: &[u8]| { - written.push((p.to_string(), b.to_vec())); - Ok(()) - }; - let mut seen = Vec::new(); - let mut progress = |i: usize, name: &str| { - seen.push((i, name.to_string())); - i < cancel_at - }; - let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes); - (files, written, seen) - } - - /// The files of a batch without their byte counts: a GPU render (when another test turns the - /// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order. - fn shape(files: &[serde_json::Value]) -> Vec { - files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect() - } - - /// Issue #675: a batch reports a render that fell back from the GPU to the CPU on that file's - /// entry (`gpuFallback`, the render's own reason) and says nothing on the others. - #[test] - fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { - if !lightcraft_gpu::available() { - lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); - return; - } - let mut s = crate::Session::with_demo(); - let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); - let o = ExportOptions::from_json(&json!({"format": "png", "width": 64})); - // one lane: both photos render on this thread, and the fault hits the first render here - lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::Limit(1 << 10)); - let (files, _, _) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); - let files = files.unwrap(); - assert_eq!(files.len(), 2, "{files:?}"); - let why: Vec<_> = files.iter().filter_map(|f| f.get("gpuFallback").and_then(serde_json::Value::as_str)).collect(); - assert_eq!(why.len(), 1, "exactly one file fell back: {files:?}"); - assert!(why[0].contains("limit"), "{files:?}"); - assert!(files[1].get("gpuFallback").is_none(), "the second render was fine: {files:?}"); - assert!(lightcraft_gpu::available(), "a limit is no device failure"); - } - - // Issue #496: photos rendered side by side give the same files, names, order and progress as - // Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture - #[test] - fn rendered_dng_matches_the_rendered_tiff() { - let mut s = crate::Session::with_demo(); - let id = s.visible()[0]; - let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap(); - assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name); - let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap(); - let raw = lightcraft_raw::decode(&dng.bytes).unwrap(); - assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3)); - assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one"); - let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); - let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image; - assert_eq!((reference.width, reference.height), (back.width, back.height)); - // mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB - let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::() / back.data.len() as f32; - let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::() / reference.data.len() as f32; - assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}"); - } - - // one at a time, and a cancel stops at the same photo - #[test] - fn side_by_side_batches_match_one_at_a_time() { - let mut s = crate::Session::with_demo(); - let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); - // one name for all: the Unique numbering follows the order the files are placed in - let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"})); - let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); - let files = files.unwrap(); - let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect(); - assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]); - let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect(); - assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}"); - for lanes in [2, 3, 8] { - let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX); - assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes"); - assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::>(), paths, "{lanes} lanes"); - assert_eq!(sn, seen, "{lanes} lanes"); - } - let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2); - assert_eq!(shape(&f.unwrap()), shape(&files[..2])); - assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); - } - - // A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it - #[test] - fn a_panic_in_progress_does_not_hang_the_lanes() { - let mut s = crate::Session::with_demo(); - let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); - let o = ExportOptions::from_json(&json!({"format": "png", "width": 40})); - let items = prepare_batch(&mut s, &ids, &o).unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let to = Destination { dir: "out".into(), exact: None }; - let mut write = |_: &str, _: &[u8]| Ok(()); - let mut calls = 0; - let mut progress = |_: usize, _: &str| { - calls += 1; - assert!(calls < 2, "progress panics on its second call"); - true - }; - let r = crate::guard::catch("test batch", || { - run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2) - }); - tx.send(r.and_then(|r| r)).ok(); - }); - let r = rx.recv_timeout(std::time::Duration::from_secs(60)); - assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}"); - } - - // A photo that fails is reported in its place while the others are exported; with - // stop_on_error the batch ends there and nothing after it is written - #[test] - fn side_by_side_batches_report_failures_in_order() { - use lightcraft_catalog::{Op, Photo, PhotoId, Source}; - let mut s = crate::Session::with_demo(); - let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect(); - let gone = - Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00"); - s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap(); - ids.insert(1, PhotoId(9_496)); - let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40})); - let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX); - let files = files.unwrap(); - assert_eq!(files.len(), 4); - assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}"); - assert!(files[1]["error"].is_string()); - assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}"); - assert_eq!(written.len(), 3); - let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap(); - assert_eq!(shape(&files), shape(&one)); - - let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX); - assert!(r.is_err()); - assert_eq!(written.len(), 1, "only the photo before the failure"); - } - - #[test] - fn export_weights_follow_the_source_and_output_sizes() { - use crate::media::SourceLevel; - let mb = |b: usize| b >> 20; - // a full-size 24 MP export (measured peak ~1.2 GB) - assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098); - // 2048 px from the 2560 px preview (measured ~150 MB) - assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145); - // a photo smaller than the preview level is its own source - assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12); - assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0); - assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow"); - } -} +mod tests; diff --git a/crates/engine/src/export/batch.rs b/crates/engine/src/export/batch.rs new file mode 100644 index 000000000..a4bb859e2 --- /dev/null +++ b/crates/engine/src/export/batch.rs @@ -0,0 +1,198 @@ +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 +/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when +/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the +/// policy. `progress(done, next file)` is called before each photo; returning false cancels the +/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`, +/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`. +/// +/// Several photos render side by side ([`export_parallelism`], within the memory of +/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's +/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the +/// files are the same as one at a time. +pub fn run_batch( + items: Vec, + o: &ExportOptions, + to: &Destination, + write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &dyn Fn(&str) -> bool, + stop_on_error: bool, + progress: &mut dyn FnMut(usize, &str) -> bool, +) -> Result, String> { + let lanes = export_parallelism(items.len()); + run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes) +} + +/// How many photos of a batch of `n` render side by side. Each render already spreads its rows +/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU. +/// One on wasm32 (no threads). +pub fn export_parallelism(n: usize) -> usize { + if cfg!(target_arch = "wasm32") { + return 1; + } + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + (cores / 4).clamp(1, 4).min(n.max(1)) +} + +/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them). +pub(super) fn run_batch_with( + items: Vec, + placer: &mut Placer<'_>, + progress: &mut dyn FnMut(usize, &str) -> bool, + lanes: usize, +) -> Result, String> { + placer.single = items.len() == 1; + if lanes <= 1 || items.len() <= 1 { + for (i, item) in items.into_iter().enumerate() { + if !progress(i, &item.file_name) { + break; + } + let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); + placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; + } + return Ok(std::mem::take(&mut placer.out)); + } + let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); + let queue = Queue { + lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), + cv: std::sync::Condvar::new(), + ahead: lanes, + }; + std::thread::scope(|sc| { + // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer + // would otherwise leave them waiting and the scope's join would never return + let _stop = StopOnDrop(&queue); + for k in 1..lanes { + let q = &queue; + // a lane that can't start leaves its photos to the others (and to this thread) + if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() { + break; + } + } + let placed = || { + for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { + if !progress(i, &name) { + break; + } + let e = queue.result(i); + placer.place(photo, name, source_dir.as_deref(), &guard, e)?; + } + Ok::<_, String>(()) + }; + placed() + })?; + Ok(std::mem::take(&mut placer.out)) +} + +/// Stops a [`Queue`] when dropped, also while unwinding from a panic. +struct StopOnDrop<'a>(&'a Queue); + +impl Drop for StopOnDrop<'_> { + fn drop(&mut self) { + self.0.stop(); + } +} + +/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic +/// becomes that photo's error, not the batch's end. +fn run_gated(item: PreparedExport) -> Result { + let _held = crate::memory::export_gate().acquire(item.weight); + let what = format!("exporting {}", item.file_name); + crate::guard::catch(&what, || item.run()).and_then(|r| r) +} + +/// The photos of a batch shared by its lanes: each lane takes the next one, at most +/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for +/// their turn too (their working memory is given back to the gate as soon as they are encoded). +struct Queue { + lane: std::sync::Mutex, + cv: std::sync::Condvar, + ahead: usize, +} + +struct Lane { + items: Vec>, + /// The next photo to start. + next: usize, + /// Photos handed to the placer so far. + placed: usize, + stop: bool, + done: std::collections::HashMap>, +} + +impl Lane { + fn take_next(&mut self) -> Option<(usize, PreparedExport)> { + let i = self.next; + let item = self.items.get_mut(i)?.take()?; + self.next += 1; + Some((i, item)) + } +} + +impl Queue { + fn lock(&self) -> std::sync::MutexGuard<'_, Lane> { + self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> { + self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn finish(&self, i: usize, r: Result) { + self.lock().done.insert(i, r); + self.cv.notify_all(); + } + + /// A lane thread: run photos until none is left or the batch stops. + fn work(&self) { + loop { + let mut l = self.lock(); + let (i, item) = loop { + if l.stop || l.next >= l.items.len() { + return; + } + if l.next < l.placed.saturating_add(self.ahead) { + match l.take_next() { + Some(t) => break t, + None => return, + } + } + l = self.wait(l); + }; + drop(l); + self.finish(i, run_gated(item)); + } + } + + /// Photo `i`'s result, waiting for it; run here when no lane has started it yet. + fn result(&self, i: usize) -> Result { + let mut l = self.lock(); + loop { + if let Some(r) = l.done.remove(&i) { + l.placed = i + 1; + drop(l); + self.cv.notify_all(); + return r; + } + if l.next == i { + let Some((_, item)) = l.take_next() else { + return Err("export: photo missing from the batch".into()); + }; + drop(l); + self.finish(i, run_gated(item)); + l = self.lock(); + continue; + } + l = self.wait(l); + } + } + + fn stop(&self) { + self.lock().stop = true; + self.cv.notify_all(); + } +} diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs new file mode 100644 index 000000000..c71903ac9 --- /dev/null +++ b/crates/engine/src/export/encoding.rs @@ -0,0 +1,322 @@ +use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor}; +use super::watermark::{Watermark, draw_watermark, draw_watermark_deep}; +use super::*; + +/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). +pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { + let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); + if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { + for (p, v) in img.data.iter_mut().zip(out) { + for c in 0..3 { + p[c] = v[c].round().clamp(0.0, 255.0) as u8; + } + } + } +} + +/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). +pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { + let (w, h) = (img.width, img.height); + match &mut img.samples { + DeepSamples::U16(v) => { + let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.round().clamp(0.0, 65535.0) as u16; + } + } + } + DeepSamples::F32(v) => { + let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.max(0.0); + } + } + } + } +} + +/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. +fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { + let (radius, base) = match target { + SharpenFor::None => return None, + SharpenFor::Screen => (1usize, 0.35f32), + SharpenFor::Matte => (2, 0.6), + SharpenFor::Glossy => (1, 0.5), + }; + let k = base + * match amount { + SharpenAmount::Low => 0.6, + SharpenAmount::Standard => 1.0, + SharpenAmount::High => 1.5, + }; + if w < 3 || h < 3 { + return None; + } + let r = radius as isize; + let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { + let mut out = vec![[0.0; 3]; inp.len()]; + for y in 0..h { + for x in 0..w { + let mut acc = [0.0f32; 3]; + for d in -r..=r { + let (sx, sy) = if horizontal { + ((x as isize + d).clamp(0, w as isize - 1) as usize, y) + } else { + (x, (y as isize + d).clamp(0, h as isize - 1) as usize) + }; + let p = inp[sy * w + sx]; + acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; + } + let n = (2 * r + 1) as f32; + out[y * w + x] = acc.map(|v| v / n); + } + } + out + }; + let blur = box_pass(&box_pass(src, true), false); + Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect()) +} + +/// Encode a rendered display-referred image according to `o` (its pixels must already be in +/// `o.effective_space()`). Resizing to `long_edge` is the +/// caller's job (render at that size); sharpening is applied here. +pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { + encode_with_metadata(img, o, None) +} + +/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP. +pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let img = finish_8bit(img, o); + let space = o.effective_space(); + let profile = icc::write_named(named_space(space)); + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + // Opaque photo exports need no alpha channel (#1670). Keep real transparency for + // callers of this encoder, and leave unrelated formats on their existing path. + let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) + && img.data.iter().all(|p| p.last() == Some(&255))) + .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); + let e = match &rgb { + Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), + None => EncodeImage::rgba8(&img), + }; + let r = match o.format { + ExportFormat::Jpeg => { + // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta)); + } + ExportFormat::Png => encode::encode_png(&e, &meta), + ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), + ExportFormat::Psd => encode::encode_psd(&e, &meta), + ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), + ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), + ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta), + f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), + ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()), + }; + r.map_err(|e| e.to_string()) +} + +/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs. +fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result>) -> Result, String> { + let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) }; + let limit = u64::from(kb) * 1024; + let (mut lo, mut hi) = (1u8, o.quality.min(100)); + let mut best = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let b = jpeg(mid).map_err(|e| e.to_string())?; + if b.len() as u64 <= limit { + best = Some(b); + lo = mid + 1; + } else if mid == 1 { + break; + } else { + hi = mid - 1; + } + } + best.ok_or_else(|| format!("cannot fit within {kb} KB")) +} + +/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. +fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { + let mut img = img.clone(); + output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let space = o.effective_space(); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; + draw_watermark(&mut img, &wm); + } + img +} + +/// Output sharpening and the watermark on a high-bit-depth render. +fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { + let mut img = img.clone(); + output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; + draw_watermark_deep(&mut img, &wm); + } + img +} + +/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) +/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured +/// against the base as written, so its 8-bit rounding is handed back by the map. +pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + use lightcraft_codecs::gainmap; + let base = finish_8bit(sdr, o); + let hdr = finish_deep(hdr, o); + let DeepSamples::F32(hv) = &hdr.samples else { + return Err("HDR export needs a float HDR render".into()); + }; + let (w, h) = (base.width, base.height); + if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 { + return Err("HDR and SDR renders differ in size".into()); + } + let space = o.effective_space(); + let trc = space.trc(); + let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect(); + let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0; + let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?; + let profile = icc::write_named(named_space(space)); + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em)) +} + +/// The codecs' name of an output space (for its ICC profile). +pub fn named_space(s: OutputSpace) -> NamedSpace { + match s { + OutputSpace::Srgb => NamedSpace::Srgb, + OutputSpace::DisplayP3 => NamedSpace::DisplayP3, + OutputSpace::AdobeRgb => NamedSpace::AdobeRgb, + OutputSpace::ProPhoto => NamedSpace::ProPhoto, + OutputSpace::Rec2020 => NamedSpace::Rec2020, + } +} + +/// An 8-bit sRGB colour expressed in `space` (encoded with its curve). +pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { + if space == OutputSpace::Srgb { + return c; + } + let lin = c.map(lightcraft_color::transfer::decode_srgb8); + let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32(); + let t = space.trc(); + std::array::from_fn(|i| { + let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]; + (t.encode(v) * 255.0 + 0.5) as u8 + }) +} + +/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF, +/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image. +pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + match &r.deep { + Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta), + _ => encode_with_metadata(&r.image, o, meta), + } +} + +/// Encode a high-bit-depth image (see [`encode_rendered`]). +pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let meta_src = meta; + let img = finish_deep(img, o); + let profile = match img.samples { + DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), + DeepSamples::U16(_) => icc::write_named(named_space(img.space)), + }; + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + let (w, h) = (img.width as u32, img.height as u32); + let e = match &img.samples { + DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), + DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), + }; + let r = match (o.format, &img.samples) { + (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta), + (ExportFormat::Png, _) => encode::encode_png(&e, &meta), + (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), + (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), + (ExportFormat::RenderedDng, DeepSamples::F32(v)) => { + let m = meta_src.cloned().unwrap_or_default(); + return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); + } + (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { + lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec()) + } + (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), + (ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => { + lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, true), &meta) + } + (ExportFormat::Jxl, DeepSamples::U16(_)) => lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, false), &meta), + (f, _) => return Err(format!("{f:?} export is 8-bit only")), + }; + r.map_err(|e| e.to_string()) +} + +/// Whether the export of `photo` under `o` leaves out its location (GPS and place names). +/// (`photo` and `catalog` let library-level privacy rules add to the export option.) +pub fn removes_location(_photo: &lightcraft_catalog::Photo, _catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> bool { + o.remove_location +} + +/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. +/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). +pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { + let m = &photo.meta; + let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); + // copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL + let mut out = Metadata { + copyright: text(&m.copyright), + copyright_marked: m.copyright_status.marked(), + usage_terms: text(&m.usage_terms), + copyright_url: text(&m.copyright_url), + artist: text(&m.creator), + software: Some("LightCraft".into()), + ..Default::default() + }; + match o.metadata { + MetadataPolicy::None => return None, + MetadataPolicy::Copyright => return Some(out), + MetadataPolicy::All | MetadataPolicy::AllExceptCamera => {} + } + out.title = text(&m.title); + out.caption = text(&m.caption); + out.alt_text = text(&m.alt_text); + out.extended_description = text(&m.extended_description); + if !o.remove_location { + out.sublocation = text(&m.location); + out.city = text(&m.city); + out.state = text(&m.state); + out.country = text(&m.country); + } + let keywords = catalog.export_keywords(&m.keywords); + out.keywords = keywords.flat; + out.hierarchical_keywords = keywords.hierarchical; + out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); + out.rating = (photo.rating > 0).then_some(photo.rating as i8); + // Pixels are exported upright: orientation is baked in. + out.orientation = Some(lightcraft_meta::Orientation::Normal); + if !o.remove_location { + out.gps = m.gps.map(|(latitude, longitude)| Gps { latitude, longitude, altitude: None }); + } + if o.metadata == MetadataPolicy::All { + out.model = text(&m.camera); + out.lens_model = text(&m.lens); + out.focal_length = m.focal_mm.map(f64::from); + out.f_number = m.aperture.map(f64::from); + out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter); + out.iso = m.iso; + } + Some(out) +} diff --git a/crates/engine/src/export/options.rs b/crates/engine/src/export/options.rs new file mode 100644 index 000000000..d1b767be2 --- /dev/null +++ b/crates/engine/src/export/options.rs @@ -0,0 +1,305 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExportFormat { + #[default] + Jpeg, + Png, + Tiff, + /// A flat PSD (8/16-bit, no layers): what an image editor such as PhotoCraft saves back to. + Psd, + Webp, + Avif, + /// JPEG XL: lossy (VarDCT) or lossless, 8/16-bit, HDR as Rec. 2020 PQ. + Jxl, + /// The original file, unchanged, with an XMP sidecar holding the edits. + Original, + /// A DNG of the raw data (raw photos only), the edits embedded as XMP. + Dng, + /// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in. + RenderedDng, +} + +impl ExportFormat { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "jpeg" | "jpg" => Self::Jpeg, + "png" => Self::Png, + "tiff" | "tif" => Self::Tiff, + "psd" => Self::Psd, + "webp" => Self::Webp, + "avif" => Self::Avif, + "jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl, + "original" => Self::Original, + "dng" => Self::Dng, + "rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng, + _ => return None, + }) + } + pub fn extension(self) -> &'static str { + match self { + Self::Jpeg => "jpg", + Self::Png => "png", + Self::Tiff => "tif", + Self::Psd => "psd", + Self::Webp => "webp", + Self::Avif => "avif", + Self::Jxl => "jxl", + // the original keeps its own extension (see `ExportOptions::file_name_for`) + Self::Original => "", + Self::Dng | Self::RenderedDng => "dng", + } + } + + /// Formats that render pixels (everything but `Original` and `Dng`). + pub fn is_rendered(self) -> bool { + !matches!(self, Self::Original | Self::Dng) + } +} + +/// How the output size is chosen (see [`Resize`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ResizeMode { + /// `value` = long edge in pixels. + #[default] + LongEdge, + /// `value` = short edge in pixels. + ShortEdge, + /// `value` = width in pixels. + Width, + /// `value` = height in pixels. + Height, + /// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤ + /// `height` (Lightroom's "Width & Height"). + WidthHeight, + /// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the + /// larger; Lightroom's "Dimensions"). + Dimensions, + /// `value` = megapixels. + Megapixels, + /// `value` = percent of the full size. + Percent, +} + +/// Output size: a mode, its value(s) and whether smaller photos are left at their own size. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Resize { + pub mode: ResizeMode, + pub value: f32, + /// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`]. + pub height: u32, + /// Never upscale past the photo's own (cropped) size. + pub dont_enlarge: bool, +} + +impl Default for Resize { + fn default() -> Self { + Self { mode: ResizeMode::LongEdge, value: 2048.0, height: 2048, dont_enlarge: true } + } +} + +/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also +/// Lightroom's ceiling. A full-size or smaller export is never held to it. +pub const MAX_ENLARGED_PIXELS: f64 = 512e6; + +impl Resize { + pub fn long_edge(px: u32) -> Self { + Self { value: px as f32, ..Default::default() } + } + + /// Scale factor from a `w × h` (cropped, full-resolution) photo to the output. + pub fn scale(&self, w: f64, h: f64) -> f64 { + let (w, h) = (w.max(1.0), h.max(1.0)); + let (long, short) = (w.max(h), w.min(h)); + let v = (self.value as f64).max(0.0); + let k = match self.mode { + ResizeMode::LongEdge => v / long, + ResizeMode::ShortEdge => v / short, + ResizeMode::Width => v / w, + ResizeMode::Height => v / h, + ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h), + ResizeMode::Dimensions => { + let (a, b) = (v, self.height as f64); + (a.max(b) / long).min(a.min(b) / short) + } + ResizeMode::Megapixels => (v * 1e6 / (w * h)).sqrt(), + ResizeMode::Percent => v / 100.0, + }; + let k = if self.dont_enlarge { k.min(1.0) } else { k }; + // an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000 + // with Don't Enlarge off asked for gigapixels, more memory than a computer has + let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0)); + // at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more + // than 65535 times as long as it is short (where `clamp` would panic: min above max) + k.max(1.0 / short).min(65_535.0 / long) + } + + /// Output size for a `w × h` (cropped, full-resolution) photo. + pub fn apply(&self, w: f64, h: f64) -> (usize, usize) { + let k = self.scale(w, h); + (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) + } +} + +/// What happens when an output file already exists. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Conflict { + /// Add `-2`, `-3`, … to the name. + #[default] + Unique, + Overwrite, + /// Leave the existing file and skip the photo. + Skip, +} + +/// Where a batch's files go (Lightroom Classic's Export To). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DestinationMode { + /// One folder for the whole batch ([`Destination::dir`]). + #[default] + SpecificFolder, + /// Each photo's own folder: the folder its original is in. A photo without one on disk (a + /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. + SameAsOriginal, +} + +impl DestinationMode { + /// `specificFolder` / `sameAsOriginal` (any case). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "specificfolder" => Some(Self::SpecificFolder), + "sameasoriginal" => Some(Self::SameAsOriginal), + _ => None, + } + } +} + +impl Conflict { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "unique" => Some(Self::Unique), + "overwrite" => Some(Self::Overwrite), + "skip" => Some(Self::Skip), + _ => None, + } + } +} + +/// A named set of `app.export` params. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportPreset { + pub name: String, + pub params: serde_json::Value, +} + +/// The built-in export presets (File → Export with Preset). +pub fn builtin_presets() -> Vec { + let p = |name: &str, params: serde_json::Value| ExportPreset { name: name.into(), params }; + vec![ + p("JPEG (Small)", serde_json::json!({"format": "jpeg", "quality": 85, "longEdge": 2048, "colorSpace": "srgb"})), + p("JPEG (Large)", serde_json::json!({"format": "jpeg", "quality": 92, "longEdge": 0, "colorSpace": "srgb"})), + p("Original + Settings", serde_json::json!({"format": "original"})), + p("DNG", serde_json::json!({"format": "dng"})), + ] +} + +/// Output sharpening target (applied after resizing, in display-encoded values). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenFor { + #[default] + None, + Screen, + Matte, + Glossy, +} + +impl SharpenFor { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "screen" => Some(Self::Screen), + "matte" => Some(Self::Matte), + "glossy" => Some(Self::Glossy), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenAmount { + Low, + #[default] + Standard, + High, +} + +impl SharpenAmount { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "low" => Some(Self::Low), + "standard" => Some(Self::Standard), + "high" => Some(Self::High), + _ => None, + } + } +} + +/// Which metadata is embedded in exported files. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MetadataPolicy { + /// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright. + #[default] + All, + /// Everything except camera/lens make, model and capture settings. + AllExceptCamera, + /// Copyright and creator only. + Copyright, + /// Nothing (the sRGB profile is still embedded). + None, +} + +impl MetadataPolicy { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "all" => Some(Self::All), + "allexceptcamera" => Some(Self::AllExceptCamera), + "copyright" => Some(Self::Copyright), + "none" => Some(Self::None), + _ => None, + } + } +} + +/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or +/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HdrCurve { + #[default] + Pq, + Hlg, +} + +impl HdrCurve { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "pq" => Some(Self::Pq), + "hlg" => Some(Self::Hlg), + _ => None, + } + } + pub(super) fn codec(self) -> lightcraft_codecs::HdrTransfer { + match self { + Self::Pq => lightcraft_codecs::HdrTransfer::Pq, + Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg, + } + } +} diff --git a/crates/engine/src/export/params.rs b/crates/engine/src/export/params.rs new file mode 100644 index 000000000..3ef9316da --- /dev/null +++ b/crates/engine/src/export/params.rs @@ -0,0 +1,615 @@ +use super::options::{Conflict, DestinationMode, ExportFormat, HdrCurve, MetadataPolicy, Resize, ResizeMode, SharpenAmount, SharpenFor}; +use super::prepare::DngCompression; +use super::watermark::{Anchor, Watermark}; +use super::*; + +impl Default for ExportOptions { + fn default() -> Self { + Self { + format: ExportFormat::Jpeg, + quality: 90, + resize: None, + ppi: 240, + limit_kb: None, + sharpen: SharpenFor::None, + sharpen_amount: SharpenAmount::Standard, + naming: "{name}".into(), + start_number: 1, + destination_mode: DestinationMode::SpecificFolder, + subfolder: String::new(), + conflict: Conflict::Unique, + tiff_compression: TiffCompression::Deflate, + dng_compression: DngCompression::Lossless, + metadata: MetadataPolicy::All, + remove_location: false, + watermark: None, + color_space: OutputSpace::Srgb, + bit_depth: None, + hdr: false, + jxl_lossless: false, + jxl_effort: JxlEffort::Normal, + jxl_progressive: false, + add_to_photos: false, + photos_album: String::new(), + hdr_transfer: HdrCurve::Pq, + } + } +} + +/// The `app.export` params that are not export options: which photos, where to, a preset to start +/// from, and the desktop app's background flag. +pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"]; + +/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes). +pub const OPTION_PARAMS: &[&str] = &[ + "format", + "quality", + "resize", + "longEdge", + "shortEdge", + "width", + "height", + "megapixels", + "percent", + "fit", + "dontEnlarge", + "ppi", + "limitKb", + "sharpen", + "sharpenAmount", + "naming", + "startNumber", + "destinationMode", + "subfolder", + "conflict", + "tiffCompression", + "dngCompression", + "metadata", + "removeLocation", + "watermark", + "colorSpace", + "bitDepth", + "hdr", + "jxlLossless", + "jxlEffort", + "jxlProgressive", + "addToPhotos", + "photosAlbum", + "hdrTransfer", +]; + +/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the +/// session has the runner; see [`crate::Session::after_export`]). +pub fn apple_photos_unsupported() -> Option<&'static str> { + (!cfg!(target_os = "macos")).then_some("Apple Photos is only available on macOS") +} + +/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY` +/// together (what versions before the two margins wrote); either of those, when given, wins. +pub const WATERMARK_PARAMS: &[&str] = + &["text", "vertical", "size", "opacity", "anchor", "insetX", "insetY", "inset", "rotation", "color", "shadow", "image", "imageWidth"]; + +/// Text height as a fraction of the short edge: the range the renderer draws without clamping. +pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); + +/// Margin as a fraction of the short edge. +pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4); + +/// A graphic's width as a fraction of the photo's width. +pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0); + +/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short). +fn edit_distance(a: &str, b: &str) -> usize { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut cur = vec![i + 1; b.len() + 1]; + for (j, cb) in b.iter().enumerate() { + let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb); + let del = prev.get(j + 1).copied().unwrap_or(0) + 1; + let ins = cur.get(j).copied().unwrap_or(0) + 1; + if let Some(c) = cur.get_mut(j + 1) { + *c = sub.min(del).min(ins); + } + } + prev = cur; + } + prev.last().copied().unwrap_or(0) +} + +/// `unknown parameter \`k\``, naming the closest known key when one is near. +fn unknown_key(what: &str, k: &str, known: &[&str]) -> String { + let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min(); + match near { + Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"), + None => format!("unknown {what} `{k}` (one of {})", known.join(", ")), + } +} + +/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them. +fn whole(v: &serde_json::Value) -> Option { + v.as_i64() +} + +/// A `watermark` object as a [`Watermark`]; a legacy `inset` fills whichever of `insetX` and +/// `insetY` is missing. +fn watermark_from_json(v: &serde_json::Value) -> serde_json::Result { + let mut v = v.clone(); + if let Some(o) = v.as_object_mut() + && let Some(inset) = o.remove("inset") + { + o.entry("insetX").or_insert_with(|| inset.clone()); + o.entry("insetY").or_insert(inset); + } + serde_json::from_value(v) +} + +impl ExportOptions { + /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or + /// newer version may have written and this one doesn't know dropped, also inside `watermark`. + /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are + /// not the caller's typo, and refusing them would make a preset or Export with Previous fail + /// for good after an update. A `bitDepth` the stored format doesn't have (written at the + /// format's own depth before issue #1952) is dropped too. + pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = Self::known_keys(p); + Self::drop_unfit_depth(&mut p, ExportFormat::default()); + p + } + + /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be + /// known once the call's own params (`path`) are merged in. + pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); + if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) { + w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str())); + } + } + p + } + + /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with + /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952. + pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) { + let format = Self::export_format(p, fallback); + if let Some(o) = p.as_object_mut() + && let Some(depth) = o.get("bitDepth").and_then(whole) + && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth) + { + o.remove("bitDepth"); + } + } + + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an + /// unknown parameter or a value that cannot be read as what it is for is an error, never a + /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) + /// are accepted and left to the caller. + pub fn from_params(p: &serde_json::Value) -> crate::Result { + Self::from_params_or(p, ExportFormat::default()) + } + + /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no + /// format; the format returned is [`ExportOptions::export_format`], the one written. + pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result { + let format = Self::export_format(p, fallback); + Self::validate_as("app.export", p, Some(format))?; + Ok(Self { format, ..Self::from_json(p) }) + } + + /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of + /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there, + /// the headless backend PNG), else the default. + pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat { + use serde_json::Value; + match (p.get("format"), p.get("path").and_then(Value::as_str)) { + (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(), + (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback), + (None, None) => ExportFormat::default(), + } + } + + /// Check `app.export` params for `cmd` (the command named in the error): every key must be an + /// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the + /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] + /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. + pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { + Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default()))) + } + + /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported + /// (`None`: not known yet, as for a preset without `format`). + pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> { + use serde_json::Value; + let bad = |msg: String| crate::cmd::bad(cmd, msg); + let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; + let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect(); + let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result { + whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}"))) + }; + let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result { + v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}"))) + }; + fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> { + v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string"))) + } + let boolean = |k: &str, v: &Value| -> crate::Result { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) }; + let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> { + let s = string(cmd, k, v)?; + if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; + // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported + let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { + if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "format" => { + if ExportFormat::parse(string(cmd, k, v)?).is_none() { + return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|jxl|dng|original"))); + } + } + "quality" => { + int(k, v, 1, 100)?; + } + "ppi" => { + int(k, v, 1, u16::MAX as i64)?; + } + "limitKb" => { + int(k, v, 0, u32::MAX as i64)?; + } + "startNumber" => { + int(k, v, 0, 999_999)?; + } + "bitDepth" => { + if !matches!(whole(v), Some(8 | 10 | 16 | 32)) { + return Err(bad(format!("`{k}` must be 8, 10, 16 or 32"))); + } + } + "longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => { + num(k, v, 0.0, 1.0e9)?; + } + "fit" => one_of(k, v, &["dimensions", "widthHeight"])?, + "resize" => { + let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) }; + if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) { + return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"]))); + } + serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; + } + "dontEnlarge" | "removeLocation" | "background" | "hdr" | "jxlLossless" | "jxlProgressive" => { + boolean(k, v)?; + } + "addToPhotos" => { + if boolean(k, v)? + && let Some(why) = apple_photos_unsupported() + { + return Err(bad(format!("`{k}`: {why}"))); + } + } + "photosAlbum" => { + if string(cmd, k, v)?.contains('\0') { + return Err(bad(format!("`{k}` can't contain a NUL character"))); + } + } + "naming" | "subfolder" | "path" | "dir" | "preset" => { + string(cmd, k, v)?; + } + "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, + "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, + "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, + "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, + "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, + "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, + "jxlEffort" => one_of(k, v, &["fast", "normal"])?, + "dngCompression" => { + serde_json::from_value::(v.clone()) + .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; + } + "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, + "colorSpace" => { + if OutputSpace::parse(string(cmd, k, v)?).is_none() { + return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); + } + } + "watermark" => Self::validate_watermark(cmd, v)?, + "id" => { + int(k, v, 0, i64::MAX)?; + } + "ids" => { + let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64())); + if !ok { + return Err(bad(format!("`{k}` must be an array of photo ids"))); + } + } + _ => return Err(bad(unknown_key("parameter", k, &known))), + } + } + // issue #1952: a depth the format doesn't have would be written at the format's own depth + if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) { + let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect(); + if !depths.iter().any(|d| i64::from(*d) == depth) { + depths.sort_unstable(); + let mut list: Vec = depths.iter().map(u8::to_string).collect(); + let last = list.pop().unwrap_or_default(); + let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) }; + let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + return Err(bad(format!("`bitDepth` must be {list} for {name}"))); + } + } + Ok(()) + } + + /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). + pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { + use serde_json::Value; + let bad = |msg: String| crate::cmd::bad(cmd, msg); + let obj = match v { + Value::String(_) => return Ok(()), + Value::Object(o) => o, + _ => { + return Err(bad( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}".into(), + )); + } + }; + let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> { + match v.as_f64() { + Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()), + _ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))), + } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "text" | "image" => { + v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?; + } + "vertical" | "shadow" => { + v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?; + } + "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, + "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, + "insetX" | "insetY" | "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, + "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, + "rotation" => { + if !matches!(whole(v), Some(0 | 90 | 180 | 270)) { + return Err(bad("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)".into())); + } + } + "anchor" => { + serde_json::from_value::(v.clone()).map_err(|_| { + bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into()) + })?; + } + "color" => { + let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255)))); + if !ok { + return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into())); + } + } + _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), + } + } + watermark_from_json(v).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) + } + + /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, + /// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`, + /// `addToPhotos`, `photosAlbum`) and the + /// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H), + /// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus + /// `dontEnlarge` (default true). + /// + /// Lenient: anything unknown or unreadable falls back to the default, which suits stored + /// prefs and dialog prefill. A command that is about to export uses + /// [`ExportOptions::from_params`], which validates first. + pub fn from_json(p: &serde_json::Value) -> Self { + use serde_json::Value; + let d = Self::default(); + let s = |k: &str| p.get(k).and_then(Value::as_str); + let u = |k: &str| p.get(k).and_then(Value::as_u64); + fn enm(p: &Value, k: &str) -> Option { + p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok()) + } + Self { + format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format), + quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8), + resize: Self::resize_from_json(p), + ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), + limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), + sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), + sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), + naming: s("naming").map_or(d.naming, str::to_string), + start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), + destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), + subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), + conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), + tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { + Some("none") => TiffCompression::None, + Some("lzw") => TiffCompression::Lzw, + Some("zip" | "deflate") => TiffCompression::Deflate, + _ => d.tiff_compression, + }, + dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), + metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), + remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), + watermark: match p.get("watermark") { + Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), + Some(v @ Value::Object(_)) => watermark_from_json(v).ok(), + _ => None, + } + .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), + color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), + bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), + hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr), + jxl_lossless: p.get("jxlLossless").and_then(Value::as_bool).unwrap_or(d.jxl_lossless), + jxl_effort: s("jxlEffort").and_then(JxlEffort::parse).unwrap_or(d.jxl_effort), + jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive), + add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos), + photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()), + hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer), + } + } + + fn resize_from_json(p: &serde_json::Value) -> Option { + use serde_json::Value; + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| *v > 0.0); + let dont_enlarge = p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(true); + if let Some(r) = p.get("resize").filter(|v| v.is_object()) { + let r: Resize = serde_json::from_value(r.clone()).ok()?; + return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r }); + } + let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge }; + // `fit: widthHeight` binds each edge to its own number; the default fits either orientation + let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight")); + Some(match (f("width"), f("height")) { + (Some(w), Some(h)) => { + Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) } + } + (Some(w), None) => r(ResizeMode::Width, w), + (None, Some(h)) => r(ResizeMode::Height, h), + (None, None) => { + if let Some(v) = f("longEdge") { + r(ResizeMode::LongEdge, v) + } else if let Some(v) = f("shortEdge") { + r(ResizeMode::ShortEdge, v) + } else if let Some(v) = f("megapixels") { + r(ResizeMode::Megapixels, v) + } else { + let v = f("percent")?; + r(ResizeMode::Percent, v) + } + } + }) + } + + /// These options as `app.export` params ([`ExportOptions::from_json`] reads them back + /// unchanged; full size is written as `longEdge: 0`). + pub fn to_json(&self) -> serde_json::Value { + let mut v = serde_json::to_value(self).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.remove("resize"); + if let Some(sz) = Self::resize_json(self.resize.as_ref()).as_object() { + o.extend(sz.clone()); + } + if self.resize.is_none() { + o.insert("longEdge".into(), 0.into()); + } + o.retain(|_, v| !v.is_null()); + } + v + } + + /// Whether `p` names an output size (any of the size params, even 0 = full size). + pub fn has_size_param(p: &serde_json::Value) -> bool { + ["resize", "longEdge", "shortEdge", "width", "height", "megapixels", "percent"].iter().any(|k| p.get(k).is_some()) + } + + /// The size params of [`ExportOptions::from_json`] for `resize` (`{}` = full size). + pub fn resize_json(resize: Option<&Resize>) -> serde_json::Value { + match resize { + None => serde_json::json!({}), + Some(r) => serde_json::json!({"resize": r, "dontEnlarge": r.dont_enlarge}), + } + } + + /// The sample format the file is written with (unsupported requests fall back to the closest). + pub fn effective_depth(&self) -> OutputDepth { + match (self.format, self.bit_depth) { + (ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng, _) => OutputDepth::U8, + (ExportFormat::Png, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, + (ExportFormat::Png, _) => OutputDepth::U8, + (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, + (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, + (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::RenderedDng, _) => OutputDepth::F32Linear, + (ExportFormat::Psd, Some(8)) => OutputDepth::U8, + (ExportFormat::Psd, _) => OutputDepth::U16, + (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, + (ExportFormat::Avif, _) => OutputDepth::U8, + (ExportFormat::Jxl, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Jxl, Some(10 | 16 | 32)) => OutputDepth::U16, + (ExportFormat::Jxl, _) => OutputDepth::U8, + } + } + + /// The bit depths `format` offers (value, label); the first is its default. + pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] { + match format { + ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng => &[(8, "8-bit")], + ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], + ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], + ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], + ExportFormat::RenderedDng => &[(16, "16-bit float")], + ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], + ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")], + } + } + + /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF, + /// PNG or JPEG XL (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. + pub fn hdr_output(&self) -> bool { + self.hdr + && matches!( + (self.format, self.bit_depth), + (ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl, _) | (ExportFormat::Tiff, Some(32)) + ) + } + + /// The colour space the file is actually written in (AVIF: sRGB; AVIF, PNG and JPEG XL HDR: + /// Rec. 2020; lossy JPEG XL: Adobe RGB and ProPhoto as Rec. 2020, the widest space its encoder + /// can declare without a profile). + pub fn effective_space(&self) -> OutputSpace { + match self.format { + ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl if self.hdr => OutputSpace::Rec2020, + ExportFormat::Avif => OutputSpace::Srgb, + ExportFormat::Jxl if !self.jxl_lossless && matches!(self.color_space, OutputSpace::AdobeRgb | OutputSpace::ProPhoto) => { + OutputSpace::Rec2020 + } + _ => self.color_space, + } + } + + /// The JPEG XL encoder settings for these options, writing samples in `space`. + pub(super) fn jxl(&self, space: OutputSpace, hdr: bool) -> JxlOptions { + let colour = match space { + _ if hdr => JxlColour::Rec2020Pq, + OutputSpace::DisplayP3 => JxlColour::DisplayP3, + OutputSpace::Rec2020 => JxlColour::Rec2020, + // lossless files carry the ICC profile; lossy ones never get here (`effective_space`) + OutputSpace::Srgb | OutputSpace::AdobeRgb | OutputSpace::ProPhoto => JxlColour::Srgb, + }; + JxlOptions { quality: self.quality, lossless: self.jxl_lossless, effort: self.jxl_effort, progressive: self.jxl_progressive, colour } + } + + /// Output file name for photo `p` at 1-based position `seq` in a batch (the original's + /// extension is kept for [`ExportFormat::Original`]). + pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String { + let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; + let n = seq + self.start_number.max(1) as usize - 1; + let name = crate::rename::expand_tokens(base, p, n, 3); + let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); + let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, "")); + if name.trim().is_empty() { + // every token came out empty (no title, no capture time…): keep the original name + name = stem.to_string(); + } + if self.format == ExportFormat::Original { + let base = name.trim_end_matches('.'); + if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") } + } else { + format!("{name}.{}", self.format.extension()) + } + } +} diff --git a/crates/engine/src/export/place.rs b/crates/engine/src/export/place.rs new file mode 100644 index 000000000..f0b1ace61 --- /dev/null +++ b/crates/engine/src/export/place.rs @@ -0,0 +1,174 @@ +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 +/// renamed into place), so a failure part-way leaves any previous file intact and no truncated +/// one. Not synced to disk (issue #134): an export can always be made again from the original. +/// The native writer behind exports, renders and screenshots (app, CLI, MCP). +pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + write_with(path, bytes, false) +} + +/// [`write_file`], synced to disk: for a written file that becomes a library photo (Edit a Copy) +/// and so can't simply be exported again once the catalog points at it. +pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> { + write_with(path, bytes, true) +} + +/// Write `path` only when nothing exists there: the destination is prepared as a complete temp +/// file and published with create-new semantics, so a file that appears after a caller's check is +/// never replaced. Screenshots of an initially absent path use this instead of the overwriting +/// writer. +pub fn write_file_new(path: &str, bytes: &[u8]) -> Result<(), String> { + let p = std::path::Path::new(path); + if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + lightcraft_catalog::safe_file::write_new(p, bytes).map_err(|e| format!("{path}: {e}")) +} + +fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> { + use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync}; + let p = std::path::Path::new(path); + if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + if durable { write_atomic(p, bytes) } else { write_atomic_nosync(p, bytes) }.map_err(|e| format!("{path}: {e}")) +} + +/// The path of the sidecar with extension `ext` of the exported file `main`. +fn sidecar_path(main: &str, ext: &str) -> String { + std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string() +} + +/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, +/// sidecars, never an original) and its write, in batch order. +pub(super) struct Placer<'a> { + o: &'a ExportOptions, + to: &'a Destination, + pub(super) single: bool, + taken: std::collections::HashSet, + pub(super) out: Vec, + write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &'a dyn Fn(&str) -> bool, + stop_on_error: bool, +} + +fn join_path(a: &str, b: &str) -> String { + if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) } +} + +impl<'a> Placer<'a> { + pub(super) fn new( + o: &'a ExportOptions, + to: &'a Destination, + write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &'a dyn Fn(&str) -> bool, + stop_on_error: bool, + ) -> Placer<'a> { + Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } + } + + /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or + /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no + /// folder of its own, or whose folder is gone: never a file written somewhere else instead. + fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result { + let base = match self.o.destination_mode { + DestinationMode::SpecificFolder => self.to.dir.clone(), + DestinationMode::SameAsOriginal => { + let Some(d) = source_dir else { + return Err(format!( + "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" + )); + }; + if !(self.exists)(d) { + return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); + } + d.to_string() + } + }; + Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) + } + + /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it + /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. + pub(super) fn place( + &mut self, + photo: lightcraft_catalog::PhotoId, + name: String, + source_dir: Option<&str>, + guard: &crate::originals::OriginalGuard, + e: Result, + ) -> Result<(), String> { + use serde_json::json; + // an exact file path needs no folder + let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; + let (o, exists) = (self.o, self.exists); + let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { + Ok(v) => v, + Err(err) if self.stop_on_error => return Err(err), + Err(err) => { + self.out.push(json!({"photo": photo.0, "file": name, "error": err})); + return Ok(()); + } + }; + let dir = dir.as_str(); + // the exported file and its sidecars + let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::>(); + let path = match self.to.exact.as_deref().filter(|_| self.single) { + Some(p) => Ok(p.to_string()), + None => { + let path = join_path(dir, &e.file_name); + let taken = &self.taken; + let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p)); + if !busy(&path) { + Ok(path) + } else { + match o.conflict { + Conflict::Overwrite => Ok(path), + Conflict::Skip => { + self.out.push(json!({"skipped": path})); + return Ok(()); + } + Conflict::Unique => { + let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b))); + let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); + (2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name")) + } + } + } + } + }; + let file = path.clone().unwrap_or_else(|_| name.clone()); + let write = &mut *self.write; + let written = path.and_then(|path| { + let files = group(&path); + // never over an original, whatever the conflict policy or the exact path said + for f in &files { + guard.check(std::path::Path::new(f))?; + } + write(&path, &e.bytes)?; + let mut sidecars = Vec::new(); + for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) { + write(sc, bytes)?; + sidecars.push(sc.clone()); + } + Ok((path, files, sidecars)) + }); + match written { + Ok((path, files, sidecars)) => { + self.taken.extend(files); + let mut entry = json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}); + if let Some(why) = &e.gpu_fallback { + entry["gpuFallback"] = json!(why); + } + self.out.push(entry); + } + Err(err) if self.stop_on_error => return Err(err), + Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})), + } + Ok(()) + } +} diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs new file mode 100644 index 000000000..1fe988832 --- /dev/null +++ b/crates/engine/src/export/prepare.rs @@ -0,0 +1,402 @@ +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::*; + +/// One exported file. +pub struct Exported { + pub file_name: String, + pub bytes: Vec, + pub width: usize, + pub height: usize, + /// Files written next to it, named like it with another extension: `(extension, bytes)` (the + /// XMP sidecar of an `Original` export). + pub sidecars: Vec<(&'static str, Vec)>, + /// Set when the render was tried on the GPU and fell back to the CPU: why (see + /// `docs/gpu-pipeline.md`). The file holds the CPU render either way. + pub gpu_fallback: Option, +} + +/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). +pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) { + let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); + // whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie + let (w, h) = (w.round().max(1.0), h.round().max(1.0)); + match &o.resize { + Some(r) => r.apply(w, h), + None => (w as usize, h as usize), + } +} + +/// Render photo `id` at the requested size and encode it (or copy / convert its original for +/// [`ExportFormat::Original`] / [`ExportFormat::Dng`]). +pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { + prepare_export(session, id, o, seq)?.run() +} + +/// One photo's export, set up from the session ([`prepare_export`]). [`PreparedExport::run`] does +/// the heavy part (read, decode, render, encode) without the session, so it can run on another +/// thread. +pub struct PreparedExport { + pub photo: lightcraft_catalog::PhotoId, + pub file_name: String, + /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` + /// for a demo photo or one in the browser's storage. + pub source_dir: Option, + work: Work, + /// The library's originals, which [`run_batch`] never writes over (shared by a batch). + pub(super) guard: std::sync::Arc, + /// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`] + /// while it runs beside other photos of a batch. + pub(super) weight: usize, +} + +struct RenderWork { + job: crate::media::RenderJob, + /// The HDR rendition of a gain map JPEG (`job` renders its SDR base). + hdr_job: Option, + meta: Option, + opts: ExportOptions, +} + +enum Work { + Render(Box), + /// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the + /// raw data re-encoded as a lossless DNG with the edits in its XMP). + File { + path: String, + read: Option, + packet: String, + dng: Option, + /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). + quality: u8, + lossy_edge: Option, + /// The original must not carry a location ([`removes_location`]). + remove_location: bool, + label: String, + size: (usize, usize), + }, +} + +/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use +/// [`prepare_batch`]: it looks at the library's originals once.) +pub fn prepare_export( + session: &mut crate::Session, + id: lightcraft_catalog::PhotoId, + o: &ExportOptions, + seq: usize, +) -> Result { + let guard = std::sync::Arc::new(session.original_guard()); + prepare_guarded(session, id, o, seq, guard) +} + +/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1). +pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result, String> { + let guard = std::sync::Arc::new(session.original_guard()); + ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect() +} + +fn prepare_guarded( + session: &mut crate::Session, + id: lightcraft_catalog::PhotoId, + o: &ExportOptions, + seq: usize, + guard: std::sync::Arc, +) -> Result { + let p = session.catalog.photo(id).ok_or("no such photo")?; + let file_name = o.file_name_for(p, seq); + let source_dir = original_folder(&p.source); + // HDR output only for photos edited in HDR; the rest of the batch exports as usual + let sdr_opts; + let o = if o.hdr && !p.develop.hdr.enabled { + sdr_opts = ExportOptions { hdr: false, ..o.clone() }; + &sdr_opts + } else { + o + }; + let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize); + let (work, weight) = if o.format.is_rendered() { + let (w, h) = output_size(p, o); + let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); + let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize; + let label = p.file_name.clone(); + let meta = export_metadata(p, &session.catalog, o); + let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg; + let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; + let weight = render_weight(full_px, long, job.level, w.saturating_mul(h)); + if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) { + return Err(format!("{label}: {why}")); + } + let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None }; + (Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight) + } else { + let lightcraft_catalog::Source::File { path } = &p.source else { + return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); + }; + let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression); + // the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again + let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 }); + let work = Work::File { + path: path.clone(), + read: session.media.file_bytes.clone(), + packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), + dng, + quality: o.quality, + lossy_edge: o.resize.is_some().then(|| { + let (w, h) = output_size(p, o); + u32::try_from(w.max(h)).unwrap_or(u32::MAX) + }), + remove_location: removes_location(p, &session.catalog, o), + label: p.file_name.clone(), + size: (p.width as usize, p.height as usize), + }; + (work, weight) + }; + Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) +} + +/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo +/// photo, and on the web, where an original lives in the browser's storage, not in a folder. +fn original_folder(source: &lightcraft_catalog::Source) -> Option { + let lightcraft_catalog::Source::File { path } = source else { return None }; + if cfg!(target_arch = "wasm32") { + return None; + } + let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); + (!dir.is_empty()).then_some(dir) +} + +/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size, +/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export +/// enlarged past the photo's size that needs more memory than the computer has would fail to +/// allocate, which ends the process rather than the export. Full-size and smaller exports, and +/// computers whose memory isn't known, are never refused. +pub(super) fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option) -> Option { + let ram = ram?; + if out.0.saturating_mul(out.1) <= native_px || weight <= ram { + return None; + } + let gb = |b: usize| b as f64 / (1u64 << 30) as f64; + Some(format!( + "exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size", + out.0, + out.1, + gb(weight).ceil(), + gb(ram) + )) +} + +/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level +/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the +/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export +/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB. +pub(super) fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize { + let edge = level.max_edge().min(long.max(1)); + let scale = edge as f64 / long.max(1) as f64; + let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize; + src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36)) +} + +impl PreparedExport { + pub fn run(self) -> Result { + let file_name = self.file_name; + match self.work { + Work::Render(w) => { + let RenderWork { job, hdr_job, meta, opts } = *w; + let result = job.run(); + let r = result.rendered?; + let gpu_fallback = result.gpu_fallback; + let bytes = match hdr_job { + Some(hj) => { + let hr = hj.run().rendered?; + let hdr = hr.deep.ok_or("the HDR render has no float samples")?; + encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())? + } + None => encode_rendered(&r, &opts, meta.as_ref())?, + }; + Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) + } + Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { + let bytes = match &read { + Some(r) => r(&path)?, + None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, + }; + let Some(dng) = dng else { + // the file is copied byte for byte: refuse rather than leak a location it embeds + if remove_location { + let metadata = lightcraft_meta::try_extract(&bytes) + .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?; + if metadata.gps.is_some() { + return Err(format!( + "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" + )); + } + } + return Ok(Exported { + file_name, + bytes, + width: size.0, + height: size.1, + sidecars: vec![("xmp", packet.into_bytes())], + gpu_fallback: None, + }); + }; + if lightcraft_raw::probe(&bytes).is_none() { + return Err(format!("{label}: DNG export needs a raw photo")); + } + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); + drop(bytes); + let compression = match dng { + DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, + DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, + DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, + DngCompression::Lossy => { + lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } + } + }; + let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) + .map_err(|e| e.to_string())?; + Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None }) + } + } + } +} + +/// How an exported DNG stores its raw data. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DngCompression { + /// Lossless JPEG (the usual DNG compression). + #[default] + Lossless, + /// Deflate (zip) tiles with prediction. + Deflate, + Uncompressed, + /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize + /// setting when there is one (Lightroom's "Use Lossy Compression"). + Lossy, +} + +/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy +/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each +/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file +/// already there is replaced). Either way a catalogued original (or its sidecar) +/// is never written over: see [`crate::originals`]. +#[derive(Clone, Debug, Default)] +pub struct Destination { + pub dir: String, + pub exact: Option, +} + +/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or +/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by +/// [`crate::Session::after_export`] before the export starts, so an export asking for something +/// this session can't do fails before writing anything; for Photos it holds the import slot +/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when +/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its +/// worker. +#[derive(Default)] +pub struct AfterExport { + #[cfg(not(target_arch = "wasm32"))] + apple_photos: Option, +} + +impl AfterExport { + /// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving + /// the import slot of `imports` now. (Front ends get this from + /// [`crate::Session::after_export`], which checks the platform first.) + #[cfg(not(target_arch = "wasm32"))] + pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result { + Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) }) + } + + /// Nothing to do. + #[cfg(not(target_arch = "wasm32"))] + pub fn is_none(&self) -> bool { + self.apple_photos.is_none() + } + + /// Nothing to do. + #[cfg(target_arch = "wasm32")] + pub fn is_none(&self) -> bool { + true + } + + /// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written + /// count), waiting for Photos: for a worker thread (the app's background export) or a caller + /// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the + /// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false, + /// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the + /// export was cancelled or wrote nothing). Never fails the export: its files are written + /// either way. + pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = self.apple_photos { + return Some(r.run(files, cancelled)); + } + let _ = (files, cancelled); + None + } + + /// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the + /// outcome is the running job (`{job, running: true, requested, album}`; its result comes + /// from `export.photosImports`, and the desktop app announces it). For a caller on the UI + /// thread, which must not wait minutes for Photos. + pub fn start(self, files: &[serde_json::Value]) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = self.apple_photos { + return Some(r.start(files)); + } + let _ = files; + None + } + + /// [`AfterExport::start`] where the session answers on a UI thread + /// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`]. + pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if s.apple_photos_background { + return self.start(files); + } + let _ = s; + self.run(files, false) + } +} + +impl crate::Session { + /// What an export with `o` does after writing its files; an error, before anything is written, + /// when it asks for Apple Photos and this platform or session has no way to reach it, or + /// another import is reserved or running. For Photos the import slot is reserved from now on. + pub fn after_export(&self, o: &ExportOptions) -> Result { + if !o.add_to_photos { + return Ok(AfterExport::default()); + } + if let Some(why) = apple_photos_unsupported() { + return Err(why.into()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?; + let mut after = AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)?; + after.apple_photos = after.apple_photos.map(|r| r.shown_in(self.activity.clone())); + Ok(after) + } + #[cfg(target_arch = "wasm32")] + Err("Apple Photos is only available on macOS".into()) + } +} + +/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error. +pub fn export_batch( + session: &mut crate::Session, + ids: &[lightcraft_catalog::PhotoId], + o: &ExportOptions, + to: &Destination, + write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &dyn Fn(&str) -> bool, +) -> Result, String> { + let items = prepare_batch(session, ids, o)?; + run_batch(items, o, to, write, exists, true, &mut |_, _| true) +} diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs new file mode 100644 index 000000000..5ad311c7d --- /dev/null +++ b/crates/engine/src/export/tests.rs @@ -0,0 +1,1434 @@ +use super::*; +use serde_json::json; + +fn test_image() -> Rgba8 { + let mut img = Rgba8::new(96, 64); + for (i, p) in img.data.iter_mut().enumerate() { + let (x, y) = (i % 96, i / 96); + *p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255]; + } + img +} + +/// Issue #1670: removing a redundant alpha channel preserves samples and metadata. +#[test] +fn opaque_eight_bit_exports_are_rgb() { + let img = test_image(); + let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; + let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); + assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); + assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); + assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); + if format == ExportFormat::Png { + assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); + } + if format != ExportFormat::Tiff { + break; + } + } + } +} + +#[test] +fn eight_bit_exports_preserve_real_alpha() { + let mut img = test_image(); + img.data[0][3] = 128; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(decoded.has_alpha, "{format:?} must retain real alpha"); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); + } +} + +#[test] +fn formats_have_magic() { + let img = test_image(); + for (f, magic) in + [(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")] + { + let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap(); + assert!(b.starts_with(magic), "{f:?}"); + } +} + +#[test] +fn size_limit_respected() { + let img = test_image(); + let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap(); + let kb = (full.len() / 1024 / 2).max(2) as u32; + let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap(); + assert!(b.len() <= kb as usize * 1024); +} + +#[test] +fn hdr_jpeg_size_limit_respected() { + let img = Rgba8::from_fn(256, 192, |x, y| { + let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663); + [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255] + }); + let hdr = DeepImage { + width: img.width, + height: img.height, + space: OutputSpace::Srgb, + samples: DeepSamples::F32(img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect()), + }; + let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; + let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); + let kb = (full.len() / 2048) as u32; + let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap(); + assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len()); + let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap(); + assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting"); + let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None); + assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB"))); +} + +#[test] +fn graphic_watermark_honours_exif_orientation() { + let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); + for orientation in [2, 6] { + let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&meta); + let bytes = lightcraft_codecs::encode_jpeg( + &EncodeImage::rgba8(&logo), + 100, + ChromaSubsampling::S444, + &EncodeMeta { exif: Some(&exif), ..Default::default() }, + ) + .unwrap(); + let path = dir.join(format!("oriented-{orientation}.jpg")); + std::fs::write(&path, bytes).unwrap(); + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: if orientation == 6 { 0.25 } else { 0.5 }, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; + let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + if orientation == 6 { + assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue"); + } else { + assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red"); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() { + let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + rotation, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1, + lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1, + ); + assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn graphic_watermark_fit_preserves_anchor_insets() { + let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + use Anchor::*; + for (anchor, rotation, expected) in [ + (BottomRight, 90, (0, 44, 111, 71)), + (TopLeft, 270, (8, 8, 119, 35)), + (Right, 90, (0, 26, 111, 53)), + (BottomRight, 0, (94, 0, 111, 71)), + (TopLeft, 180, (8, 8, 25, 79)), + (Bottom, 0, (51, 0, 68, 71)), + // Centered axes ignore their inset rather than shrinking the image unnecessarily. + (Center, 90, (0, 25, 119, 54)), + (Top, 90, (0, 8, 119, 37)), + ] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.1, + inset_y: 0.1, + rotation, + anchor, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).min().unwrap(), + lit.iter().map(|p| p.1).min().unwrap(), + lit.iter().map(|p| p.0).max().unwrap(), + lit.iter().map(|p| p.1).max().unwrap(), + ); + assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn sharpen_increases_edge_contrast() { + let mut img = Rgba8::new(8, 8); + for (i, p) in img.data.iter_mut().enumerate() { + let v = if i % 8 < 4 { 80 } else { 170 }; + *p = [v, v, v, 255]; + } + output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High); + assert!(img.data[3][0] < 80 && img.data[4][0] > 170); +} + +/// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` +/// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of +/// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. +#[test] +fn exported_keywords_follow_their_options() { + use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo}; + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); + p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()]; + let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap(); + assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]); + assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]); + let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap(); + let back = lightcraft_meta::extract(&jpg); + assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone())); +} + +#[test] +fn metadata_policies() { + use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); + p.meta.camera = "Synthetic X2".into(); + p.meta.copyright = "(c) Me".into(); + p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted; + p.meta.usage_terms = "Editorial use only".into(); + p.meta.copyright_url = "https://example.com/rights".into(); + p.meta.shutter = "1/250".into(); + p.meta.gps = Some((43.0, -110.0)); + let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap(); + assert_eq!(all.model.as_deref(), Some("Synthetic X2")); + assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9); + assert!(all.gps.is_some()); + let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() }; + let m = export_metadata(&p, &Catalog::new(), &o).unwrap(); + assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); + let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); + assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); + // "copyright only" keeps all the copyright info: status, usage terms, info URL + assert_eq!( + (c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()), + (Some(true), Some("Editorial use only"), Some("https://example.com/rights")) + ); + assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); + // embedded and readable back from the JPEG + let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); + let back = lightcraft_meta::extract(&jpg); + assert_eq!(back.model.as_deref(), Some("Synthetic X2")); + assert_eq!(back.copyright.as_deref(), Some("(c) Me")); + assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only"))); + assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights")); + assert!(back.gps.is_some()); +} + +#[test] +fn watermark_draws_in_the_anchored_corner_only() { + let mut img = Rgba8::new(400, 300); + for p in img.data.iter_mut() { + *p = [0, 0, 0, 255]; + } + let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}})); + let wm = o.watermark.clone().unwrap(); + draw_watermark(&mut img, &wm); + let lit = |x0: usize, x1: usize, y0: usize, y1: usize| { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).count() + }; + assert!(lit(200, 400, 225, 300) > 100, "bottom-right has text"); + assert!(lit(0, 200, 0, 150) == 0, "top-left untouched"); + // string shorthand + assert_eq!(ExportOptions::from_json(&serde_json::json!({"watermark": "© Me"})).watermark.unwrap().text, "© Me"); + assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); +} + +/// Horizontal and vertical margins move a text watermark separately; settings saved with one +/// `inset` (before the two margins) read as both, and either new key overrides it. +#[test] +fn watermark_insets_are_horizontal_and_vertical() { + // gaps between the drawn text and the right and bottom edges of a 400 × 300 image + let gaps = |wm: &Watermark| { + let mut img = Rgba8::new(400, 300); + draw_watermark(&mut img, wm); + let lit: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); + let right = lit.iter().map(|p| p.0).max().unwrap(); + let bottom = lit.iter().map(|p| p.1).max().unwrap(); + (399 - right as i64, 299 - bottom as i64) + }; + let base = Watermark { text: "LC".into(), size: 0.1, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + let (r0, b0) = gaps(&base); + // 0.2 and 0.1 of the 300 px short edge + let (r1, b1) = gaps(&Watermark { inset_x: 0.2, ..base.clone() }); + assert!((r1 - r0 - 60).abs() <= 1 && (b1 - b0).abs() <= 1, "horizontal: {r0},{b0} → {r1},{b1}"); + let (r2, b2) = gaps(&Watermark { inset_y: 0.1, ..base.clone() }); + assert!((r2 - r0).abs() <= 1 && (b2 - b0 - 30).abs() <= 1, "vertical: {r0},{b0} → {r2},{b2}"); + // the old single margin + let wm = |w: serde_json::Value| ExportOptions::from_json(&json!({"watermark": w})).watermark.unwrap(); + let old = wm(json!({"text": "LC", "inset": 0.1})); + assert_eq!((old.inset_x, old.inset_y), (0.1, 0.1)); + let mixed = wm(json!({"text": "LC", "inset": 0.1, "insetY": 0.3})); + assert_eq!((mixed.inset_x, mixed.inset_y), (0.1, 0.3)); + assert!(ExportOptions::from_params(&json!({"watermark": {"text": "LC", "inset": 0.1}})).is_ok()); + // saved and read back as the two margins + let o = ExportOptions { watermark: Some(Watermark { inset_x: 0.05, inset_y: 0.15, ..base }), ..Default::default() }; + let saved = o.to_json(); + assert!(saved["watermark"].get("inset").is_none(), "{saved}"); + assert_eq!(ExportOptions::from_json(&saved).watermark.map(|w| (w.inset_x, w.inset_y)), Some((0.05, 0.15))); +} + +/// A turned text watermark is placed by its turned box: taller than wide at 90° and 270°, the +/// same corner and insets, and an unknown angle draws it unturned. +#[test] +fn watermark_rotation_turns_the_text_in_its_corner() { + let lit = |wm: &Watermark| { + let mut img = Rgba8::new(400, 300); + draw_watermark(&mut img, wm); + let on: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); + let (x0, x1) = (on.iter().map(|p| p.0).min().unwrap(), on.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (on.iter().map(|p| p.1).min().unwrap(), on.iter().map(|p| p.1).max().unwrap()); + (x0, x1, y0, y1, img) + }; + let base = Watermark { text: "LightCraft".into(), size: 0.08, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + let (a0, a1, b0, b1, flat) = lit(&base); + assert!(a1 - a0 > 2 * (b1 - b0), "unturned text is wide"); + for rotation in [90, 270] { + let (x0, x1, y0, y1, _) = lit(&Watermark { rotation, ..base.clone() }); + assert!(y1 - y0 > 2 * (x1 - x0), "{rotation}°: tall"); + assert!(x1 >= 390 && y1 >= 290, "{rotation}°: still in the bottom-right corner ({x1}, {y1})"); + // 0.1 × 300 px further from the right edge + let (_, moved, ..) = lit(&Watermark { rotation, inset_x: 0.1, ..base.clone() }); + assert!((x1 as i64 - moved as i64 - 30).abs() <= 1, "{rotation}°: {x1} → {moved}"); + } + let (x0, x1, y0, y1, _) = lit(&Watermark { rotation: 180, ..base.clone() }); + assert!(((x1 - x0) as i64 - (a1 - a0) as i64).abs() <= 2 && ((y1 - y0) as i64 - (b1 - b0) as i64).abs() <= 2, "180°: same size"); + assert!(x1 >= 390 && y1 >= 290, "180°: same corner"); + let (.., odd) = lit(&Watermark { rotation: 45, ..base.clone() }); + assert_eq!(odd.data, flat.data, "an angle that isn't a quarter turn draws unturned"); + // saved and read back; older settings have none + let o = ExportOptions { watermark: Some(Watermark { rotation: 270, ..base }), ..Default::default() }; + assert_eq!(ExportOptions::from_json(&o.to_json()).watermark.map(|w| w.rotation), Some(270)); + assert_eq!(ExportOptions::from_json(&json!({"watermark": {"text": "x"}})).watermark.map(|w| w.rotation), Some(0)); +} + +/// A preview keeps the export's 6 px minimum text size as a share of the photo: a watermark +/// clamped to 6 px in a 150 px export covers the same share of a 600 px preview. +#[test] +fn watermark_preview_scales_the_minimum_text_size() { + let wm = Watermark { text: "LightCraft".into(), size: 0.01, opacity: 1.0, shadow: false, ..Default::default() }; + let rows = |img: &Rgba8| (0..img.height).filter(|&y| (0..img.width).any(|x| img.get(x, y)[0] > 64)).count() as f32; + let mut export = Rgba8::new(200, 150); + draw_watermark(&mut export, &wm); + let mut preview = Rgba8::new(800, 600); + draw_watermark_preview(&mut preview, &wm, 150); + let mut unscaled = Rgba8::new(800, 600); + draw_watermark(&mut unscaled, &wm); + let (export_share, preview_share) = (rows(&export) / 150.0, rows(&preview) / 600.0); + assert!((preview_share - export_share).abs() < 0.02, "export {export_share}, preview {preview_share}"); + assert!(rows(&unscaled) < rows(&preview) / 2.0, "the unscaled 6 px floor would draw it too small"); + // drawn at the export's own size, the preview is the export + let mut same = Rgba8::new(200, 150); + draw_watermark_preview(&mut same, &wm, 150); + assert_eq!(same.data, export.data); +} + +fn vertical_japanese() -> Watermark { + let options = ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); + options.watermark.unwrap() +} + +#[test] +fn japanese_watermark_options_and_legacy_defaults() { + let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); + assert!(!old.watermark.unwrap().vertical); + let wm = vertical_japanese(); + assert!(wm.vertical); + let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap(); + assert!(round.vertical); +} + +/// Built without craft-fonts, a Japanese watermark still lays out and draws (Inter's +/// missing-glyph boxes), and Latin text is unaffected. +#[test] +fn watermarks_work_without_craft_fonts() { + assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); + for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { + let mut covered = 0usize; + watermark_coverage(400, 300, &wm, &[], 1.0, |_, _, k, _| covered += usize::from(k > 0.0)); + assert!(covered > 0, "{:?} draws something", wm.text); + } +} + +#[test] +fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { + use ab_glyph::Font; + let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { + lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); + return; + }; + let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); + // BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。. + assert_eq!(font.glyph_id('︑').0, 0); + assert_eq!(font.glyph_id('︒').0, 0); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap()); + for ch in ['、', '。'] { + let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0); + assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}"); + let rect = font.outline_glyph(glyph).unwrap().px_bounds(); + assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}"); + } + let craft = Box::leak( + vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(), + ); + let wm = Watermark { + text: "、。".into(), + vertical: true, + size: 0.1, + anchor: Anchor::TopLeft, + inset_x: 0.0, + inset_y: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = Vec::new(); + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| { + if k > 0.5 { + pixels.push((x, y)); + } + }); + assert!(!pixels.is_empty()); + for (x, y) in pixels { + assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}"); + } +} + +#[test] +fn vertical_watermarks_keep_combining_marks_in_one_cell() { + let coverage = |text: &str, craft| { + let wm = Watermark { + text: text.into(), + vertical: true, + size: 0.1, + anchor: Anchor::Center, + inset_x: 0.0, + inset_y: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = vec![0.0f32; 400 * 300]; + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| pixels[y * 400 + x] = k); + assert!(pixels.iter().any(|&k| k > 0.0)); + pixels + }; + let compare = |a: &str, b: &str, craft| { + let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft)); + let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b); + assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}"); + }; + for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] { + compare(composed, decomposed, &[]); + } + if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") { + for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] { + compare(composed, decomposed, crate::fonts::CRAFT_FONTS); + } + } else { + lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + } +} + +#[test] +fn upright_watermark_cell_retains_multiple_glyphs() { + use ab_glyph::{Font, ScaleFont}; + let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap(); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap()); + // There is no precomposed A with a combining long solidus overlay. + let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap(); + assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph"); + let baseline = font.as_scaled(30.0).ascent(); + for glyph in glyphs { + assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark"); + assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink"); + } +} + +#[test] +fn japanese_watermarks_support_vertical_columns() { + use ab_glyph::Font; + let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); + if fonts.len() < 2 { + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + return; + } + for c in "日本語の文字".chars() { + assert!(fonts[1..].iter().any(|f| f.glyph_id(c).0 != 0), "a craft-fonts face has {c}"); + } + let wm = vertical_japanese(); + let mut img = Rgba8::new(400, 300); + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect(); + assert!(lit.len() > 100); + let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); + let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); + assert!(h > w * 2, "vertical Japanese must extend down the column"); + let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset_x: 0.0, inset_y: 0.0, ..wm }; + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &two); + for (text, dx) in [("本", 0), ("日", 30)] { + let mut single = Rgba8::new(400, 300); + single.data.fill([0, 0, 0, 255]); + draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() }); + for y in 0..30 { + for x in 0..30 { + assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left"); + } + } + } +} + +#[test] +fn graphic_watermark_is_placed_scaled_and_blended() { + let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo_path = dir.join("logo.png"); + // 40×20: left half opaque red, right half fully transparent + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] }); + let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap(); + std::fs::write(&logo_path, png).unwrap(); + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + let wm = + Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + draw_watermark(&mut img, &wm); + // 100 px wide × 50 px high in the bottom-right corner + assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); + assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo"); + assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo"); + assert_eq!(img.get(10, 10), [0, 0, 0, 255]); + // 30 px from the right edge and 15 px from the bottom: x 270..370, y 235..285 + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + draw_watermark(&mut img, &Watermark { inset_x: 0.1, inset_y: 0.05, ..wm.clone() }); + assert!(img.get(272, 283)[0] > 200, "inside the moved logo: {:?}", img.get(272, 283)); + assert_eq!(img.get(267, 283), [0, 0, 0, 255], "left of the logo"); + assert_eq!(img.get(272, 288), [0, 0, 0, 255], "below the logo"); + // turned: 180° puts the opaque half on the right; 90° clockwise puts it on top of a 50 × 100 box + let turned = |rotation: u16| { + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + draw_watermark(&mut img, &Watermark { rotation, ..wm.clone() }); + img + }; + let half = turned(180); + assert!(half.get(390, 280)[0] > 200 && half.get(310, 280) == [0, 0, 0, 255]); + let quarter = turned(90); + assert!(quarter.get(375, 220)[0] > 200, "top half of the turned logo: {:?}", quarter.get(375, 220)); + assert_eq!(quarter.get(375, 280), [0, 0, 0, 255], "its transparent half below"); + assert_eq!(quarter.get(340, 220), [0, 0, 0, 255], "left of the 50 px wide box"); + assert!(quarter.get(352, 202)[0] > 200 && quarter.get(398, 248)[0] > 200, "the whole box is covered"); + // three quarter turns: the opaque half at the bottom + let three = turned(270); + assert!(three.get(375, 280)[0] > 200 && three.get(375, 220) == [0, 0, 0, 255]); + // an image-only watermark survives the params (no text needed) + let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}})); + assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn naming_and_params() { + let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); + assert_eq!(o.format, ExportFormat::Jpeg); + assert_eq!(o.quality, 100); + assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) })); + assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg"); + assert_eq!(o.ppi, 240); + let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() }; + assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF"); + assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001"); + assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng"); +} + +fn named(file_name: &str) -> lightcraft_catalog::Photo { + use lightcraft_catalog::{Photo, PhotoId, Source}; + Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00") +} + +/// Export naming takes the same tokens as Rename Photos and import renaming. +#[test] +fn naming_uses_the_rename_tokens() { + let mut p = named("DSC_0815.NEF"); + p.captured = Some("2026-09-30T14:05:09".into()); + p.meta.camera = "Model X/2".into(); + p.meta.title = "Harbour".into(); + p.rating = 5; + let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() }; + assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg"); + assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _"); + assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits"); + assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg"); + // nothing left after expanding: the original name + p.meta.title.clear(); + assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg"); +} + +#[test] +fn params_round_trip() { + let o = ExportOptions { + format: ExportFormat::Tiff, + quality: 70, + resize: Some(Resize { mode: ResizeMode::Megapixels, value: 12.5, height: 0, dont_enlarge: false }), + ppi: 300, + limit_kb: Some(500), + sharpen: SharpenFor::Glossy, + naming: "{date}-{name}-{seq}".into(), + start_number: 42, + subfolder: "Client".into(), + conflict: Conflict::Skip, + tiff_compression: TiffCompression::Lzw, + metadata: MetadataPolicy::Copyright, + remove_location: true, + watermark: Some(Watermark { text: "© LC".into(), ..Default::default() }), + color_space: OutputSpace::ProPhoto, + bit_depth: Some(16), + ..Default::default() + }; + assert_eq!(ExportOptions::from_json(&o.to_json()), o); + let full = ExportOptions::default(); + assert_eq!(ExportOptions::from_json(&full.to_json()), full); + assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit"); + let mut p = named("IMG.png"); + p.captured = Some("2026-09-30T10:00:00".into()); + assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif"); +} + +#[test] +fn resize_modes() { + let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)); + // a 6000 × 4000 landscape and a 4000 × 6000 portrait + assert_eq!(size(json!({"longEdge": 1500}), 6000.0, 4000.0), Some((1500, 1000))); + assert_eq!(size(json!({"longEdge": 1500}), 4000.0, 6000.0), Some((1000, 1500))); + assert_eq!(size(json!({"shortEdge": 1080}), 6000.0, 4000.0), Some((1620, 1080))); + assert_eq!(size(json!({"width": 3000}), 4000.0, 6000.0), Some((3000, 4500))); + assert_eq!(size(json!({"height": 1000}), 6000.0, 4000.0), Some((1500, 1000))); + // W × H fits either orientation: the long edge gets the larger number + assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667))); + assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000))); + assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500))); + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225))); + // Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620) + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180))); + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337))); + assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160))); + // `fit` only matters with both numbers + assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667))); + let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap(); + assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}"); + assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000))); + // don't enlarge (default) vs enlarge + assert_eq!(size(json!({"longEdge": 8000}), 6000.0, 4000.0), Some((6000, 4000))); + assert_eq!(size(json!({"longEdge": 9000, "dontEnlarge": false}), 6000.0, 4000.0), Some((9000, 6000))); + // full size / absent + assert_eq!(size(json!({"longEdge": 0}), 6000.0, 4000.0), None); + assert_eq!(size(json!({}), 6000.0, 4000.0), None); + assert!(ExportOptions::has_size_param(&json!({"longEdge": 0})) && !ExportOptions::has_size_param(&json!({"quality": 3}))); + // the `resize` object form round-trips (what the dialog and prefs store) + let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false }; + let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize; + assert_eq!(back, Some(r)); + let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true }; + assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r)); + assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None); + assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300); + // never larger than the encoders' limit, never empty + assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most"); + assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge"); + assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1)); +} + +/// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the +/// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the +/// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos +/// larger than that are untouched. +#[test] +fn enlargements_stop_at_512_megapixels() { + let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap(); + for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] { + let (w, h) = size(p.clone(), 6000.0, 4000.0); + assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}"); + assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape"); + } + // below the ceiling an enlargement is as asked + assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000)); + // a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged + assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); + assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000)); + assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); +} + +/// An enlarged export that needs more memory than the computer has is refused with a message, +/// before it starts; everything else goes ahead. +#[test] +fn enlarged_exports_must_fit_the_computers_memory() { + let gb = 1usize << 30; + let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap(); + assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}"); + assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits"); + assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused"); + assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export"); + assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory"); +} + +#[test] +fn resize_size_survives_the_render() { + // the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312) + let cropped = |r: Resize, w: u32, h: u32, x1: f64| { + let mut p = named("IMG.ARW"); + (p.width, p.height) = (w, h); + std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0); + let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() }); + lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh)) + }; + let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0); + let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true }; + // a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5 + let hair = 1.0 - 1e-9; + assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365)); + assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000)); + // 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says + for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] { + for (w, h) in [(w, h), (h, w)] { + for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] { + for r in [ + Resize::long_edge(value as u32), + mode(ResizeMode::ShortEdge, value, 0), + mode(ResizeMode::Width, value, 0), + mode(ResizeMode::Height, value, 0), + mode(ResizeMode::Dimensions, value, 1365), + mode(ResizeMode::WidthHeight, value, 1365), + mode(ResizeMode::Megapixels, value / 1000.0, 0), + mode(ResizeMode::Percent, value / 100.0, 0), + ] { + let want = r.apply(w.into(), h.into()); + assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}"); + } + } + } + } +} + +/// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale` +/// clamp with its lower bound above its upper one, which panics, in the export and in the +/// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a +/// pixel. +#[test] +fn resize_survives_photos_too_long_for_the_encoders() { + let modes = [ + json!({"longEdge": 2000}), + json!({"shortEdge": 1}), + json!({"width": 500, "height": 500}), + json!({"megapixels": 1}), + json!({"percent": 50}), + json!({"percent": 1000, "dontEnlarge": false}), + ]; + for p in modes { + for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] { + let r = ExportOptions::from_json(&p).resize.unwrap(); + let (ow, oh) = r.apply(w, h); + assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}"); + assert!(r.scale(w, h).is_finite()); + } + } + // (a short edge under a pixel would be scaled up to one, which the limit then caps) + assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1)); + assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535)); +} + +/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. +fn p3_green(space: OutputSpace) -> Rgba8 { + use lightcraft_color::{DISPLAY_P3, REC2020}; + let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]); + let src = lightcraft_raster::Rgb32f::filled(16, 16, c); + let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image +} + +/// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the +/// recognised space of the embedded profile. +fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let m = d.space.to_space(&lightcraft_color::SRGB); + (m.apply_f32(d.image.get(8, 8)), d.space.named) +} + +#[test] +fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() { + let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; + let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap()); + assert_eq!(named, Some(NamedSpace::DisplayP3)); + assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}"); + let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap()); + assert_eq!(named, Some(NamedSpace::Srgb)); + assert!(s.iter().all(|v| *v > -0.002), "{s:?}"); + // the green itself is about the same brightness either way + assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}"); +} + +#[test] +fn every_space_embeds_its_own_profile_and_round_trips() { + let grey = { + let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]); + let base = lightcraft_pipeline::RenderRequest::fit(16, 16); + move |space| { + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }).image + } + }; + let reference = + decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0; + for space in OutputSpace::ALL { + for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let o = ExportOptions { format, color_space: space, ..Default::default() }; + let bytes = encode_image(&grey(space), &o).unwrap(); + let (px, named) = decoded_in_srgb(&bytes); + assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}"); + // a neutral grey decodes to the same linear value whatever the output space + for c in 0..3 { + assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}"); + } + } + let icc = icc::write_named(named_space(space)); + let info = icc::parse(&icc).expect("our profile parses"); + assert_eq!(info.named, Some(named_space(space))); + assert!(info.description.as_deref().is_some_and(|d| !d.is_empty())); + } + let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_space(), OutputSpace::Srgb); + let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"})); + assert_eq!(o.color_space, OutputSpace::DisplayP3); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb); +} + +#[test] +fn srgb_watermark_colour_is_converted() { + assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]); + for s in OutputSpace::ALL { + assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}"); + assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}"); + } + let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); + assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); +} + +/// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`. +fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered { + let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]); + let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) +} + +fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let mut v: Vec = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect(); + v.dedup(); + (v.len(), d) +} + +#[test] +fn sixteen_bit_tiff_and_png_have_no_banding() { + let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; + assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit"); + let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb); + assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); + let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap()); + let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb); + let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap()); + eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}"); + assert!(levels8 < 40, "{levels8}"); + assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}"); + assert_eq!(d16.space.named, Some(NamedSpace::Srgb)); + // the 8-bit preview of a deep render matches the 8-bit render + for (a, b) in r16.image.data.iter().zip(&r8.image.data) { + assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}"); + } + let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() }; + let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3); + let bytes = encode_rendered(&rp, &png, None).unwrap(); + assert_eq!(bytes[24], 16, "PNG IHDR bit depth"); + let (levels, d) = distinct_levels(&bytes); + assert!(levels > 10 * levels8, "{levels}"); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + // sharpening and watermarking work on deep images too + let o = + ExportOptions { sharpen: SharpenFor::Matte, watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), ..tiff(None) }; + assert!(encode_rendered(&r16, &o, None).is_ok()); +} + +#[test] +fn float_tiff_is_linear_with_a_linear_profile() { + let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_depth(), OutputDepth::F32Linear); + let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto); + let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap()); + assert!(levels > 500, "{levels}"); + assert_eq!(df.space.named, Some(NamedSpace::ProPhoto)); + assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc); + // decodes to the same light as the 16-bit gamma-encoded export + let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() }; + let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap()); + for x in [0, 300, 700, 1023] { + let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]); + assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}"); + } +} + +#[test] +fn jxl_options() { + assert_eq!(ExportFormat::parse("JPEG-XL"), Some(ExportFormat::Jxl)); + let o = ExportOptions::from_params(&json!({"format": "jxl", "jxlLossless": true, "jxlEffort": "fast", "jxlProgressive": true})).unwrap(); + assert_eq!((o.format, o.jxl_lossless, o.jxl_effort, o.jxl_progressive), (ExportFormat::Jxl, true, JxlEffort::Fast, true)); + assert_eq!(ExportOptions::from_json(&o.to_json()), o, "saved presets keep the JPEG XL settings"); + assert!(ExportOptions::from_params(&json!({"jxlEffort": "best"})).is_err()); + assert!(ExportOptions::from_params(&json!({"jxlLossless": "yes"})).is_err()); + let space = |lossless, color_space, hdr| { + ExportOptions { format: ExportFormat::Jxl, jxl_lossless: lossless, color_space, hdr, ..Default::default() }.effective_space() + }; + assert_eq!(space(false, OutputSpace::DisplayP3, false), OutputSpace::DisplayP3); + assert_eq!(space(false, OutputSpace::ProPhoto, false), OutputSpace::Rec2020, "lossy has no ProPhoto enum"); + assert_eq!(space(true, OutputSpace::ProPhoto, false), OutputSpace::ProPhoto, "lossless keeps the profile"); + assert_eq!(space(true, OutputSpace::Srgb, true), OutputSpace::Rec2020); + let o = ExportOptions { format: ExportFormat::Jxl, hdr: true, ..Default::default() }; + assert!(o.hdr_output()); + assert_eq!(o.effective_depth(), OutputDepth::F32Hdr); + assert_eq!(ExportFormat::Jxl.extension(), "jxl"); +} + +#[test] +fn jxl_every_space_round_trips() { + // wide-gamut green stays outside sRGB in P3, Rec. 2020 and (lossless) ProPhoto files + for (lossless, space) in [ + (false, OutputSpace::Srgb), + (false, OutputSpace::DisplayP3), + (false, OutputSpace::Rec2020), + (true, OutputSpace::DisplayP3), + (true, OutputSpace::AdobeRgb), + (true, OutputSpace::ProPhoto), + ] { + let o = ExportOptions { format: ExportFormat::Jxl, color_space: space, jxl_lossless: lossless, quality: 95, ..Default::default() }; + let png = ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; + let img = p3_green(space); + let bytes = encode_image(&img, &o).unwrap(); + assert_eq!(lightcraft_codecs::sniff(&bytes), Some(lightcraft_codecs::Format::Jxl)); + let (got, _) = decoded_in_srgb(&bytes); + let (want, _) = decoded_in_srgb(&encode_image(&img, &png).unwrap()); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 0.01, "{space:?} lossless={lossless}: {got:?} vs PNG {want:?}"); + } + if space != OutputSpace::Srgb { + assert!(got[0] < -0.03 || got[2] < -0.03, "{space:?}: outside sRGB after the round trip: {got:?}"); + } + } +} + +#[test] +fn jxl_sixteen_bit() { + let o = ExportOptions { + format: ExportFormat::Jxl, + bit_depth: Some(16), + jxl_lossless: true, + color_space: OutputSpace::DisplayP3, + ..Default::default() + }; + assert_eq!(o.effective_depth(), OutputDepth::U16); + let (levels8, _) = distinct_levels( + &encode_rendered(&ramp(OutputDepth::U8, OutputSpace::DisplayP3), &ExportOptions { bit_depth: Some(8), ..o.clone() }, None).unwrap(), + ); + let (levels, d) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &o, None).unwrap()); + assert_eq!(d.bit_depth, 16); + assert!(levels > 10 * levels8, "{levels} vs {levels8}"); + // lossy 16-bit encodes too + let lossy = ExportOptions { jxl_lossless: false, ..o }; + assert!(encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &lossy, None).is_ok()); +} + +#[test] +fn bit_depth_options_per_format() { + let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth(); + assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8); + assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16); + assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16)); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None); + assert_eq!(d(ExportFormat::Jxl, Some(16)), OutputDepth::U16); + assert_eq!(d(ExportFormat::Jxl, None), OutputDepth::U8); + for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif, ExportFormat::Jxl] { + let first = ExportOptions::bit_depths(f)[0].0; + let def = d(f, None); + assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn avif_ten_bit() { + let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() }; + let r = ramp(OutputDepth::U16, OutputSpace::Srgb); + match encode_rendered(&r, &o, None) { + Ok(b) => assert_eq!(&b[4..8], b"ftyp"), + Err(e) => assert!(e.contains("not available"), "{e}"), + } +} + +/// Issue #181: a command about to export refuses what `from_json` would silently default — +/// unknown keys (naming the nearest known one) and values of the wrong kind or range. +#[test] +fn export_params_are_validated() { + 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})); + assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); + assert!(err(json!({"qualty": 5})).contains("did you mean `quality`")); + assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number")); + assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100")); + assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer")); + assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer")); + assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy")); + assert!(err(json!({"format": "bmp"})).contains("`format` must be one of")); + assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32")); + assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of")); + assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false")); + assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight")); + assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`")); + assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:")); + assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids")); + assert!(err(json!("jpeg")).contains("params must be an object")); + assert!( + ExportOptions::validate("export.savePreset", &json!({"qualty": 5})) + .unwrap_err() + .to_string() + .starts_with("invalid parameters for `export.savePreset`") + ); + // 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)); + } + let full = ExportOptions { + resize: Some(Resize::default()), + limit_kb: Some(500), + watermark: Some(Watermark { text: "©".into(), ..Default::default() }), + format: ExportFormat::Png, + bit_depth: Some(16), + ..Default::default() + }; + let mut j = full.to_json(); + for (k, v) in [ + ("dir", json!("/tmp/out")), + ("background", json!(true)), + ("preset", json!("x")), + ("ids", json!([1, 2])), + ("id", json!(1)), + ("path", json!("a.jpg")), + ] { + j[k] = v; + } + assert_eq!(ExportOptions::from_params(&j).unwrap(), full); + // Add to Apple Photos (issue #236): a switch and an album name; on only where Photos is + assert!(err(json!({"addToPhotos": "yes"})).contains("`addToPhotos` must be true or false")); + assert!(err(json!({"photosAlbum": 3})).contains("`photosAlbum` must be a string")); + assert!(err(json!({"photosAlbum": "a\0b"})).contains("NUL")); + assert!(err(json!({"photosAlbm": "x"})).contains("did you mean `photosAlbum`")); + let album = ExportOptions { photos_album: "Trip “2026” \\ \"x\"".into(), ..Default::default() }; + assert_eq!(ExportOptions::from_params(&album.to_json()).unwrap(), album); + let on = ExportOptions { add_to_photos: true, ..album }; + if cfg!(target_os = "macos") { + assert_eq!(ExportOptions::from_params(&on.to_json()).unwrap(), on); + } else { + assert!(err(on.to_json()).contains("Apple Photos is only available on macOS")); + // stored settings (a preset or Export with Previous made on a Mac) still read + assert!(ExportOptions::from_json(&on.to_json()).add_to_photos); + } + assert_eq!(ExportOptions::from_json(&json!({"photosAlbum": " Trip "})).photos_album, "Trip"); + assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default()); + // null is "not given", as a dropped key is + ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); +} + +/// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers +/// it), not written at the format's own depth; the error names the depths the format takes. +#[test] +fn bit_depth_must_suit_the_format() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg")); + assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png")); + assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff")); + // no `format`: the one `path` names, else the default (JPEG) + assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png")); + assert!(err(json!({"bitDepth": 16})).contains("for jpeg")); + assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok()); + // a path naming no format, or a `null` format, exports the default too + assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg")); + // or the caller's own format for such a path, which is the one returned + let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png); + assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png")); + assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png); + assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff); + // stored settings (a preset, Export with Previous) lose such a depth instead of failing + let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(stored.get("bitDepth"), None); + assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32); + for f in [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff, + ExportFormat::Psd, + ExportFormat::Webp, + ExportFormat::Avif, + ExportFormat::Jxl, + ExportFormat::Original, + ExportFormat::Dng, + ExportFormat::RenderedDng, + ] { + for &(b, _) in ExportOptions::bit_depths(f) { + let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() }; + assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}"); + } + } +} + +/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own +/// folder; it is checked like the other params and kept in presets and Export with Previous. +#[test] +fn destination_mode_param() { + use serde_json::json; + assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); + for (v, want) in [ + ("sameAsOriginal", DestinationMode::SameAsOriginal), + ("SameAsOriginal", DestinationMode::SameAsOriginal), + ("specificFolder", DestinationMode::SpecificFolder), + ] { + assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); + } + let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); + assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); + assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); + assert!( + ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})).unwrap_err().to_string().contains("did you mean `destinationMode`") + ); + // saved (a preset, `lastExport`) and read back + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let j = o.to_json(); + assert_eq!(j["destinationMode"], "sameAsOriginal"); + assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); +} + +/// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the +/// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and +/// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and +/// `conflict` had the same gap. +#[test] +fn accepted_choices_are_the_ones_exported() { + use serde_json::json; + let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); + let metadata = [ + ("all", MetadataPolicy::All), + ("allExceptCamera", MetadataPolicy::AllExceptCamera), + ("copyright", MetadataPolicy::Copyright), + ("none", MetadataPolicy::None), + ]; + for (name, want) in metadata { + for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { + assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); + } + } + for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] { + assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); + } + for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { + assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); + } + for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { + assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); + } + // an explicit value nothing reads as is an error, never the permissive default + for v in ["", " none", "nothing", "all-except-camera"] { + let e = read("metadata", v).unwrap_err(); + assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); + } + assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); + assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); + // absent (or null) keeps the defaults + let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); + assert_eq!( + (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), + (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) + ); + // `removeLocation` stays independent of the policy's spelling + let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); + assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); + // stored settings (lenient `from_json`) read the same spellings + assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); + // and `to_json` still writes what the MCP schema lists + let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); + assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); +} + +/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in +/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. +#[test] +fn watermark_params_are_validated() { + use serde_json::json; + let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(json!(5)).contains( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}" + )); + assert!(err(json!({"text": "x", "bogus": 1})).contains( + "unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth)" + )); + assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); + for size in [json!(3), json!(0.5001), json!(0), json!("big")] { + let e = err(json!({"text": "x", "size": size})); + assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}"); + } + assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); + assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetX": 0.5})).contains("`watermark.insetX` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetY": "far"})).contains("`watermark.insetY` must be a number 0..0.4")); + for rotation in [json!(45), json!(-90), json!(360), json!("90"), json!(90.5)] { + let e = err(json!({"text": "x", "rotation": rotation})); + assert!(e.contains("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)"), "{rotation}: {e}"); + } + assert!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "rotation": 270}})).is_ok()); + assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); + assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); + assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); + assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`")); + assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false")); + assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string")); + // the documented shapes pass and read as before + let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap(); + assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me")); + let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3}); + let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap(); + assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow); + assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5)); + // the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges + assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15); + assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0); +} + +/// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there): +/// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`. +#[allow(clippy::type_complexity)] +fn lanes_batch( + s: &mut crate::Session, + ids: &[lightcraft_catalog::PhotoId], + o: &ExportOptions, + lanes: usize, + stop_on_error: bool, + cancel_at: usize, +) -> (Result, String>, Vec<(String, Vec)>, Vec<(usize, String)>) { + let items = prepare_batch(s, ids, o).unwrap(); + let to = Destination { dir: "out".into(), exact: None }; + let mut written = Vec::new(); + let mut write = |p: &str, b: &[u8]| { + written.push((p.to_string(), b.to_vec())); + Ok(()) + }; + let mut seen = Vec::new(); + let mut progress = |i: usize, name: &str| { + seen.push((i, name.to_string())); + i < cancel_at + }; + let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes); + (files, written, seen) +} + +/// The files of a batch without their byte counts: a GPU render (when another test turns the +/// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order. +fn shape(files: &[serde_json::Value]) -> Vec { + files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect() +} + +/// Issue #675: a batch reports a render that fell back from the GPU to the CPU on that file's +/// entry (`gpuFallback`, the render's own reason) and says nothing on the others. +#[test] +fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { + if !lightcraft_gpu::available() { + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); + return; + } + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 64})); + // one lane: both photos render on this thread, and the fault hits the first render here + lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::Limit(1 << 10)); + let (files, _, _) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); + let files = files.unwrap(); + assert_eq!(files.len(), 2, "{files:?}"); + let why: Vec<_> = files.iter().filter_map(|f| f.get("gpuFallback").and_then(serde_json::Value::as_str)).collect(); + assert_eq!(why.len(), 1, "exactly one file fell back: {files:?}"); + assert!(why[0].contains("limit"), "{files:?}"); + assert!(files[1].get("gpuFallback").is_none(), "the second render was fine: {files:?}"); + assert!(lightcraft_gpu::available(), "a limit is no device failure"); +} + +// Issue #496: photos rendered side by side give the same files, names, order and progress as +// Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture +#[test] +fn rendered_dng_matches_the_rendered_tiff() { + let mut s = crate::Session::with_demo(); + let id = s.visible()[0]; + let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap(); + assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name); + let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap(); + let raw = lightcraft_raw::decode(&dng.bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3)); + assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one"); + let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image; + assert_eq!((reference.width, reference.height), (back.width, back.height)); + // mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB + let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::() / back.data.len() as f32; + let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::() / reference.data.len() as f32; + assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}"); +} + +// one at a time, and a cancel stops at the same photo +#[test] +fn side_by_side_batches_match_one_at_a_time() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + // one name for all: the Unique numbering follows the order the files are placed in + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"})); + let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); + let files = files.unwrap(); + let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]); + let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect(); + assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}"); + for lanes in [2, 3, 8] { + let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX); + assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes"); + assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::>(), paths, "{lanes} lanes"); + assert_eq!(sn, seen, "{lanes} lanes"); + } + let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2); + assert_eq!(shape(&f.unwrap()), shape(&files[..2])); + assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); +} + +// A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it +#[test] +fn a_panic_in_progress_does_not_hang_the_lanes() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40})); + let items = prepare_batch(&mut s, &ids, &o).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let to = Destination { dir: "out".into(), exact: None }; + let mut write = |_: &str, _: &[u8]| Ok(()); + let mut calls = 0; + let mut progress = |_: usize, _: &str| { + calls += 1; + assert!(calls < 2, "progress panics on its second call"); + true + }; + let r = + crate::guard::catch("test batch", || run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2)); + tx.send(r.and_then(|r| r)).ok(); + }); + let r = rx.recv_timeout(std::time::Duration::from_secs(60)); + assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}"); +} + +// A photo that fails is reported in its place while the others are exported; with +// stop_on_error the batch ends there and nothing after it is written +#[test] +fn side_by_side_batches_report_failures_in_order() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut s = crate::Session::with_demo(); + let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect(); + let gone = Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap(); + ids.insert(1, PhotoId(9_496)); + let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40})); + let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX); + let files = files.unwrap(); + assert_eq!(files.len(), 4); + assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}"); + assert!(files[1]["error"].is_string()); + assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}"); + assert_eq!(written.len(), 3); + let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap(); + assert_eq!(shape(&files), shape(&one)); + + let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX); + assert!(r.is_err()); + assert_eq!(written.len(), 1, "only the photo before the failure"); +} + +#[test] +fn export_weights_follow_the_source_and_output_sizes() { + use crate::media::SourceLevel; + let mb = |b: usize| b >> 20; + // a full-size 24 MP export (measured peak ~1.2 GB) + assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098); + // 2048 px from the 2560 px preview (measured ~150 MB) + assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145); + // a photo smaller than the preview level is its own source + assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12); + assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0); + assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow"); +} diff --git a/crates/engine/src/export/watermark.rs b/crates/engine/src/export/watermark.rs new file mode 100644 index 000000000..68633794b --- /dev/null +++ b/crates/engine/src/export/watermark.rs @@ -0,0 +1,306 @@ +use super::encoding::srgb8_in; +use super::*; + +/// Where a watermark sits. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Anchor { + TopLeft, + Top, + TopRight, + Left, + Center, + Right, + BottomLeft, + Bottom, + #[default] + BottomRight, +} + +/// A text watermark, sized relative to the image so every export size looks the same. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Watermark { + pub text: String, + /// Basic Japanese vertical lettering: upright glyphs in columns from right to left. + pub vertical: bool, + /// Text height as a fraction of the image's short edge. + pub size: f32, + /// 0..1. + pub opacity: f32, + pub anchor: Anchor, + /// Margin from the left or right edge, as a fraction of the short edge (unused when the anchor + /// is centred across: top, center, bottom). + pub inset_x: f32, + /// Margin from the top or bottom edge, as a fraction of the short edge (unused when the anchor + /// is centred down: left, center, right). + pub inset_y: f32, + /// Degrees clockwise: 0, 90, 180 or 270 (anything else draws unrotated). The watermark is + /// sized before it is turned, and the insets are measured to the turned watermark. + pub rotation: u16, + /// sRGB colour. + pub color: [u8; 3], + /// Soft dark drop shadow for legibility on bright areas. + pub shadow: bool, + /// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text). + pub image: String, + /// The graphic's width as a fraction of the photo's width. + pub image_width: f32, + /// The colour space the photo is in (set by the encoder; the graphic is converted into it). + #[serde(skip)] + pub target: Option, +} + +impl Default for Watermark { + fn default() -> Self { + Self { + text: String::new(), + vertical: false, + size: 0.035, + opacity: 0.7, + anchor: Anchor::BottomRight, + inset_x: 0.025, + inset_y: 0.025, + rotation: 0, + color: [255; 3], + shadow: true, + image: String::new(), + image_width: 0.2, + target: None, + } + } +} + +impl Watermark { + /// `rotation` in clockwise quarter turns. + fn quarter_turns(&self) -> u8 { + match self.rotation { + 90 => 1, + 180 => 2, + 270 => 3, + _ => 0, + } + } +} + +/// Where the point `(x, y)` of a `w × h` box lands when the box is turned `turns` quarter turns +/// clockwise (for pixel coordinates: the turned box is `h × w` for odd turns). +fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) { + match turns { + 1 => (h - 1.0 - y, x), + 2 => (w - 1.0 - x, h - 1.0 - y), + 3 => (y, w - 1.0 - x), + _ => (x, y), + } +} + +/// Draw `wm` onto `img` (straight alpha blending of the encoded values). +pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { + blend_watermark(img, wm, 1.0); +} + +/// Draw `wm` onto `img`, a preview of an export whose short edge is `export_short_edge` pixels. +/// The smallest text size and shadow offset scale with the preview, so it looks like the file. +pub fn draw_watermark_preview(img: &mut Rgba8, wm: &Watermark, export_short_edge: usize) { + let short = img.width.min(img.height); + let scale = if export_short_edge == 0 { 1.0 } else { short as f32 / export_short_edge as f32 }; + blend_watermark(img, wm, scale); +} + +fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { + let width = img.width; + watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, floor_scale, |x, y, k, col| { + let p = &mut img.data[y * width + x]; + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + }); +} + +/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and +/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. +pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { + let (width, height) = (img.width, img.height); + watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); + if px >= width || py >= height { + return; + } + if let Some(p) = img.data.get_mut(py * width + px) { + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + } + }); +} + +/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float +/// images are linear and blend in linear light). +pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { + let (width, trc) = (img.width, img.space.trc()); + match &mut img.samples { + DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let p = v[i + c] as f32; + v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; + } + }), + DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let target = trc.decode(col[c] as f32 / 255.0); + v[i + c] += (target - v[i + c]) * k; + } + }), + } +} + +/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for +/// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter +/// so tests can render without it). `floor_scale` is this image's size over the exported file's +/// (1 when drawing the file itself): the 6 px minimum text size is a minimum in the file. +pub(super) fn watermark_coverage( + width: usize, + height: usize, + wm: &Watermark, + craft: &'static [crate::fonts::CraftFont], + floor_scale: f32, + mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), +) { + if !wm.image.trim().is_empty() { + logo_coverage(width, height, wm, blend_px); + return; + } + let text = wm.text.trim(); + if text.is_empty() || width == 0 || height == 0 { + return; + } + let short = width.min(height) as f32; + let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); + let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; + let (tw, th) = (layout.width, layout.height); + // laid out unturned in a tw × th box, placed by the box it becomes + let turns = wm.quarter_turns(); + let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) }; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); + let (w, h) = (width as f32, height as f32); + use Anchor::*; + let ox = match wm.anchor { + TopLeft | Left | BottomLeft => ix, + Top | Center | Bottom => (w - bw) / 2.0, + TopRight | Right | BottomRight => w - ix - bw, + }; + let oy = match wm.anchor { + TopLeft | Top | TopRight => iy, + Left | Center | Right => (h - bh) / 2.0, + BottomLeft | Bottom | BottomRight => h - iy - bh, + }; + let alpha = wm.opacity.clamp(0.0, 1.0); + let passes: &[(f32, [u8; 3], f32)] = + if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; + for &(off, col, a) in passes { + let mut put = |gx: i32, gy: i32, cov: f32| { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { + return; + } + blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); + }; + if turns == 0 { + layout.draw_in(ox + off, oy + off, w, h, &mut put); + } else { + // turned with the box; the shadow still falls down and to the right + layout.draw_points(|x, y, cov| { + let (x, y) = turn(turns, x, y, tw, th); + put((ox + off + x) as i32, (oy + off + y) as i32, cov); + }); + } + } +} + +/// A watermark graphic, decoded once per file (and again when the file changes). +pub(crate) fn watermark_logo(path: &str) -> Option> { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + static CACHE: Mutex)>>> = Mutex::new(None); + let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let map = c.get_or_insert_with(HashMap::new); + if let Some((m, img)) = map.get(path) + && *m == modified + { + return Some(img.clone()); + } + let img = Arc::new(read_graphic(path).ok()?); + map.insert(path.to_string(), (modified, img.clone())); + Some(img) +} + +/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the +/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it. +/// The error says why it couldn't be read or decoded. +pub fn read_graphic(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; + Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation))) +} + +/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to +/// `image_width` of the photo, placed like the text, blended with its alpha × opacity. +fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { + let Some(logo) = watermark_logo(wm.image.trim()) else { + log::warn!("watermark: can't read {}", wm.image); + return; + }; + if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { + return; + } + let turns = wm.quarter_turns(); + // Reserve the inset on anchored axes before fitting the turned box. Centered axes + // ignore insets, just as placement below does. One scale preserves the graphic's shape. + let short = width.min(height) as f32; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); + use Anchor::*; + let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 }; + let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 }; + let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) }; + let scale = + (wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64); + let lw = (logo.width as f64 * scale).round().max(1.0) as usize; + let lh = (logo.height as f64 * scale).round().max(1.0) as usize; + let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { + let p = logo.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); + let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; + let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); + let ox = match wm.anchor { + TopLeft | Left | BottomLeft => ix, + Top | Center | Bottom => (w - lwf) / 2.0, + TopRight | Right | BottomRight => w - ix - lwf, + } + .max(0.0) as usize; + let oy = match wm.anchor { + TopLeft | Top | TopRight => iy, + Left | Center | Right => (h - lhf) / 2.0, + BottomLeft | Bottom | BottomRight => h - iy - lhf, + } + .max(0.0) as usize; + let space = wm.target.unwrap_or(OutputSpace::Srgb); + let op = wm.opacity.clamp(0.0, 1.0); + for y in 0..lh { + for x in 0..lw { + let [r, g, b, a] = scaled.get(x, y); + let a = a.clamp(0.0, 1.0); + let (tx, ty) = turn(turns, x as f32, y as f32, lw as f32, lh as f32); + let (px, py) = (ox + tx as usize, oy + ty as usize); + if a <= 1e-4 || px >= width || py >= height { + continue; + } + let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); + blend_px(px, py, a * op, srgb8_in(space, c)); + } + } +} diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 82d4c57d6..5ab68ff0e 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -13,225 +13,26 @@ use lightcraft_raster::resample::{Filter, fit}; use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo, ReducedLoader}; -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(); - let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string(); - let meta = Meta { - camera, - // (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers) - lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(), - focal_mm: m.focal_length.map(|f| f as f32), - aperture: m.f_number.map(|f| f as f32), - shutter, - iso: m.iso, - location: m.sublocation.clone().unwrap_or_default(), - city: m.city.clone().unwrap_or_default(), - state: m.state.clone().unwrap_or_default(), - country: m.country.clone().unwrap_or_default(), - alt_text: m.alt_text.clone().unwrap_or_default(), - extended_description: m.extended_description.clone().unwrap_or_default(), - gps: m.gps.as_ref().map(|g| (g.latitude, g.longitude)), - title: m.title.clone().unwrap_or_default(), - caption: m.caption.clone().unwrap_or_default(), - copyright: m.copyright.clone().unwrap_or_default(), - copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked), - usage_terms: m.usage_terms.clone().unwrap_or_default(), - copyright_url: m.copyright_url.clone().unwrap_or_default(), - creator: m.artist.clone().unwrap_or_default(), - // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels - keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), - regions: m.regions.clone(), - focus: m.focus.clone(), - }; - (meta, m.capture_time.as_ref().map(|d| d.to_iso())) -} +mod colour; +mod fs; +mod load; +mod preview; +mod probe; -/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the -/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the -/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. -pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { - use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES}; - use lightcraft_geom::Point; - use lightcraft_raw::Opcode; - let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); - if aw < 2.0 || ah < 2.0 { - return None; - } - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let (cx0, cy0, cw, ch) = - if c.width == 0 || c.height == 0 { (0.0, 0.0, aw, ah) } else { (c.x as f64, c.y as f64, c.width as f64, c.height as f64) }; - let long = cw.max(ch); - // opcode centres are relative to the (uncropped) active area, in pixel-index units - let centre = |rel: [f64; 2]| -> (Point, f64) { - let (px, py) = (rel[0] * (aw - 1.0), rel[1] * (ah - 1.0)); - let m = [(0.0, 0.0), (aw - 1.0, 0.0), (0.0, ah - 1.0), (aw - 1.0, ah - 1.0)] - .iter() - .map(|&(x, y)| (x - px).hypot(y - py)) - .fold(0.0, f64::max) - .max(1e-9); - (Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long) - }; - let mut lens = EmbeddedLens::default(); - // opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional - // WarpRectilinear2) - let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect(); - // A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output); - // without one the first WarpRectilinear is the lens warp, as it always was. - let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })); - let mut stages = Vec::new(); - for op in ops { - match op { - Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => { - let (center, radius) = centre(*center); - let p = |i: usize| planes[i.min(planes.len() - 1)]; - stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal }); - } - Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => { - // WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit - let (center, radius) = centre(*center); - let p = |i: usize| { - let k = planes[i.min(planes.len() - 1)]; - let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN]; - s[0] = k[0]; - s[2] = k[1]; - s[4] = k[2]; - s[6] = k[3]; - s[15] = k[4]; - s[16] = k[5]; - s[18] = 1e6; - s - }; - stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false }); - } - Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => { - let (center, radius) = centre(*center); - let p = |i: usize| planes[i.min(planes.len() - 1)]; - lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius }); - } - Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { - let (center, radius) = centre(*center); - lens.vignette = Some(EmbeddedVignette { k: *k, center, radius }); - } - _ => {} - } - } - // (more stages than a lens keeps: the first ones; real files carry two) - for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) { - *slot = Some(st); - } - if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() { - return None; - } - Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch)) -} - -fn ext_upper(name: &str) -> String { - std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() -} - -/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed: -/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too -/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside -/// compressed data that is all there: such a file imports and shows as unreadable when rendered). -pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { - let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); - let m = lightcraft_meta::extract(bytes); - let (meta, captured) = meta_of(&m); - if lightcraft_raw::probe(bytes).is_some() { - let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) { - Ok(r) => r, - Err(Ok(why)) => { - // a raw variant we can't decode yet: describe it from its embedded preview - let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?; - return Ok(ProbeInfo { - width: w, - height: h, - format: ext_upper(name), - kind: MediaKind::Raw, - file_size: bytes.len() as u64, - captured, - meta, - as_shot_wb: None, - content_hash, - xmp: lightcraft_meta::embedded(bytes).xmp, - preview_only: Some(why), - ..Default::default() - }); - } - Err(Err(e)) => return Err(e), - }; - let (w, h) = raw.developed_size(); - let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); - if raw.orientation.swaps_axes() { - std::mem::swap(&mut w, &mut h); - } - let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); - let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw))); - let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone()); - return Ok(ProbeInfo { - embedded_lens, - width: w, - height: h, - format: ext_upper(name), - kind: MediaKind::Raw, - file_size: bytes.len() as u64, - captured, - file_modified: None, - meta, - as_shot_wb, - content_hash, - xmp: lightcraft_meta::embedded(bytes).xmp, - preview_only: None, - }); - } - let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; - if let Some(why) = fmt.not_decodable() { - // e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft" - return Err(format!("{fmt:?} files can't be opened: {why}")); - } - // headers only (issue #367: decoding the pixels was nearly all of an import's CPU time) - let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?; - let o = Orientation::from_exif(header.orientation); - let (mut w, mut h) = (header.width, header.height); - if o.swaps_axes() { - std::mem::swap(&mut w, &mut h); - } - let format = match ext_upper(name).as_str() { - "JPG" | "JPEG" => "JPEG".to_string(), - "" => format!("{fmt:?}").to_uppercase(), - e => e.to_string(), - }; - Ok(ProbeInfo { - width: w, - height: h, - format, - kind: MediaKind::Image, - file_size: bytes.len() as u64, - captured, - file_modified: None, - meta, - as_shot_wb: None, - content_hash, - embedded_lens: None, - xmp: None, - preview_only: None, - }) -} - -/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error -/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX -/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed. -/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF -/// structure): its preview may still be intact. Only a file that is not a raw reports an error. -fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result { - use lightcraft_raw::RawError; - match e { - RawError::Unsupported(why) => Ok(why), - e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")), - RawError::NotRaw => Err(RawError::NotRaw.to_string()), - e => Ok(e.to_string()), - } -} +pub use colour::bin_factor; +pub(crate) use colour::clipped_white; +#[cfg(test)] +use fs::quick_image_srgb; +pub use fs::{fs_hooks, fs_pair_loader, fs_reduced_loader}; +pub(crate) use load::{DngWhite, WbScaleOf, dng_tone_curve}; +pub use load::{load_bytes, load_vec, load_vec_pair, load_vec_reduced}; +#[cfg(test)] +use preview::PLAN_PREFIX; +pub(crate) use preview::decode_raw_preview; +pub use preview::{PreviewRead, embedded_preview_srgb, fs_preview_loader, load_embedded_preview, preview_window}; +pub use probe::{embedded_lens, probe_bytes}; +#[cfg(test)] +use probe::{meta_of, preview_reason}; /// Sensor clip level (normalised) for highlight reconstruction. const HIGHLIGHT_CLIP: f32 = 0.99; @@ -306,2630 +107,5 @@ impl CameraColourModel<'_> { } } -/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders -/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out -/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a -/// camera profile often tints it by a few per cent, which any darkening of the highlights would show). -/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not -/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per -/// cent), or one that isn't neutral through the `f32` matrix the decode applies. -pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] { - const CAMERA: [f32; 3] = [1.0; 3]; - /// Largest condition number (max row sum norm) of a model trusted for this. - const CONDITION: f64 = 1e3; - /// Largest factor between the answer and the camera's neutral, per channel. - const SPREAD: f64 = 4.0; - let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::()).fold(0.0, f64::max); - let Some(inverse) = model.inverse() else { return CAMERA }; - let condition = norm(model) * norm(&inverse); - if !condition.is_finite() || condition > CONDITION { - return CAMERA; - } - let luma = |v: [f64; 3]| { - let c = model.apply(v); - (0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::() - }; - let n = inverse.apply([1.0; 3]); - let (target, own) = (luma([1.0; 3]), luma(n)); - if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) { - return CAMERA; - } - let white = n.map(|v| v * target / own); - if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) { - return CAMERA; - } - let white = white.map(|v| v as f32); - // neutral through the matrix as the decode applies it - let m = model.to_f32(); - let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]); - let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2])); - if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA } -} - -/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels: -/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the -/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the -/// cost of Bayer's, so 3× is accepted down to 75 % (a 24 MP X-Trans preview is then ~2000 px -/// instead of a ~1.2 s full demosaic; zooming in still uses the full-size source). -/// `None` = demosaic at full size. -pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option { - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let long = if c.width > 1 && c.height > 1 { c.width.max(c.height) } else { raw.active_area.width.max(raw.active_area.height) }; - let need = (max_edge.saturating_mul(9) / 10).max(1); - let need3 = (max_edge.saturating_mul(3) / 4).max(1); - [8usize, 6, 4, 3, 2].into_iter().find(|&k| raw.can_bin(k) && (long / k >= need || (k == 3 && !raw.can_bin(2) && long / k >= need3))) -} - -/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. -/// -/// Runs on a rayon worker: its many short parallel loops then start on the worker's own queue -/// instead of each one waking the pool from outside and waiting for it (which costs more than the -/// loops themselves when the machine is busy). -pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) -} - -/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded -/// (less memory held while it is developed). -pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) -} - -/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and -/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether -/// it did: such a picture is not the full image, and an export never renders from it. -pub fn load_vec_reduced(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced))) -} - -/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain -/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all -/// there is) and the source facts. Both pictures are the same size. -pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info))) -} - -/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default -/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the -/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when -/// the product is missing, damaged, made from something else or not the size of this raw's active area. -fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option { - use lightcraft_denoise::product::{self, Window}; - let a = raw.active_area; - let header = product::read_header_at(&spec.product).ok()?; - if header.key != spec.key || (header.width, header.height) != (a.width, a.height) { - return None; - } - if raw.opcodes.list3.is_empty() { - let c = raw.develop_crop(a.width, a.height); - let window = Window { x: c.x, y: c.y, width: c.width, height: c.height }; - product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok() - } else { - // (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1) - let whole = product::read(&spec.product, &spec.key, 1).ok()?; - Some(raw.finish_demosaiced(whole)) - } -} - -fn load_bytes_now( - bytes: std::borrow::Cow<'_, [u8]>, - max_edge: usize, - denoise: Option<&DenoiseSpec>, - allow_reduced: bool, -) -> Result<(Rgb32f, Option, SourceInfo, bool), String> { - if lightcraft_raw::probe(&bytes).is_some() { - // a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image - // (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic - let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX) - .then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()) - .flatten(); - let used_reduced = reduced.is_some(); - let decoded = match reduced { - Some(r) => Ok(r), - None => lightcraft_raw::decode(&bytes), - }; - let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) { - Ok(r) => r, - Err(Ok(why)) => { - // show the camera's embedded JPEG (rendered, not raw) until the variant is supported - return load_embedded_preview(&bytes, max_edge) - .map(|(img, info)| (img, None, info, false)) - .ok_or(format!("unsupported raw ({why}) without an embedded preview")); - } - Err(Err(e)) => return Err(e), - }; - // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in - // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). - let info = raw.info(); - let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); - // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) - let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); - // A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit, - // then remove them again: the rendered source leaves those corrections to the pipeline. - let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction())) - .then(|| raw.opcodes.list3.clone()); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or - // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) - let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); - // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join( - || { - let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops)); - // With the opcodes restored, the proxy already includes vignetting; do not apply it twice. - let embedded = if removed.is_some() { None } else { lens.as_ref() }; - let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded); - if let Some(ops) = removed { - raw.opcodes.list3 = ops; - } - colour - }, - || dng_mattes(&bytes, &info), - ); - drop(bytes); - // Previews and thumbnails bin the mosaic straight to (about) the size they need; only - // larger levels (exports, 1:1) demosaic the whole sensor. - let t0 = web_time::Instant::now(); - let bin = bin_factor(&raw, max_edge); - // (binned: block means, and which colours had a clipped sample in each block) - let binned = match bin { - Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, - None => None, - }; - let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; - let (img, clip_mask) = match binned { - Some((img, mask)) => (img, Some(mask)), - None => { - let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - (raw.develop(method).map_err(|e| e.to_string())?, None) - } - }; - // the same development from the denoised mosaic, when the photo has been denoised - let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height); - // the samples aren't needed any more (the colour model below reads only the tags) - raw.data = lightcraft_raw::RawData::U16(Vec::new()); - let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new); - // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. - let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); - let colour = CameraColourModel { - wb: t.wb, - matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix), - gain: 2f32.powf(t.baseline_exposure as f32), - tables: tables.as_ref(), - hue_sat: hue_sat.as_ref(), - }; - // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, - // fitted to `max_edge` and upright (the same for the plain and the denoised picture) - let finish = |mut img: Rgb32f| { - let mut stages = vec![("develop", t0.elapsed())]; - stages.push(("transform", t0.elapsed())); - match &clip_mask { - Some(mask) => colour.rebuild_highlights_masked(&mut img, mask), - None => colour.rebuild_highlights(&mut img), - } - stages.push(("highlights", t0.elapsed())); - colour.apply(&mut img); - stages.push(("colour", t0.elapsed())); - // a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP) - let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img }; - stages.push(("fit", t0.elapsed())); - let img = img.into_oriented(raw.orientation); - stages.push(("orient", t0.elapsed())); - (img, stages) - }; - let (img, stages) = finish(img); - if lightcraft_pipeline::profiling() { - let mut prev = std::time::Duration::ZERO; - let parts: Vec = stages - .iter() - .map(|(n, t)| { - let d = *t - prev; - prev = *t; - format!("{n} {:.1}", d.as_secs_f64() * 1e3) - }) - .collect(); - eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); - } - let twin = twin.map(|picture| finish(picture).0); - // White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits - // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the - // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale - // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) - .then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); - let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); - let local_tone = local_tone(&raw); - let (temp, tint) = scale.as_shot(xy); - return Ok(( - img, - twin, - SourceInfo { - raw: true, - as_shot_temp: temp, - as_shot_tint: tint, - lens, - relative_wb: scale.relative, - legacy_relative_wb: scale.legacy_relative, - camera_color, - camera_tone, - mattes, - local_tone, - // (the catalog knows the original's size: `media::source_info`) - native_long: None, - }, - used_reduced, - )); - } - let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; - drop(bytes); - let img = d.to_working(); - let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false)) -} - -/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from -/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step). -/// -/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without -/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured -/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one -/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the -/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). -pub(crate) struct WbScaleOf { - own_matrix: bool, - relative: bool, - /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. - legacy_relative: bool, - /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. - scale_white: Option, - /// The camera's measured matrices, when they give the scale. - camera: Option<&'static lightcraft_raw::spectral::Camera>, -} - -/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does -/// ([`WbScaleOf::dng_white`]). -pub(crate) enum DngWhite { - /// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does. - Matrices(Box), - /// The white the source's scale shows at As Shot. - At(lightcraft_color::Xy), -} - -impl WbScaleOf { - pub(crate) fn new( - format: lightcraft_raw::RawFormat, - color: &lightcraft_raw::ColorData, - wb_multipliers: Option<[f32; 3]>, - meta: &lightcraft_meta::Metadata, - ) -> Self { - let own_matrix = lightcraft_raw::color::has_matrix(color); - let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; - let model = meta.model.as_deref().filter(|_| fitted); - let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model)); - let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model)); - let scale_white = match camera { - Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers), - None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers), - None => None, - }; - let covered = camera.is_some() || presets; - WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera } - } - - /// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the - /// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets' - /// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give). - pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite { - if let Some(camera) = self.camera { - let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() }; - if camera.fill(&mut c) { - return DngWhite::Matrices(Box::new(c)); - } - } - if self.relative { - return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); - } - DngWhite::At(self.scale_white.unwrap_or(own)) - } - /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through - /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the - /// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to - /// `own`, D65. - fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { - if self.relative { - return (6500.0, 0.0); - } - let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own)); - (t.round(), tint.round()) - } -} - -/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in -/// the active area: rendered when the photo's "Camera local tone mapping" option is on. -fn local_tone(raw: &lightcraft_raw::RawImage) -> Option> { - let map = raw.color.profile.gain_table_map.clone()?; - let a = raw.active_area; - let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64); - let c = raw.develop_crop(a.width, a.height); - let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah]; - let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation }; - Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement })) -} - -/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop, -/// oriented like it). Person mattes without a confidently selected pixel are left out (the -/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky). -fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option> { - use lightcraft_pipeline::masks::{MatteKind, Mattes}; - if raw.format != lightcraft_raw::RawFormat::Dng { - return None; - } - let mut mattes = Mattes::default(); - for m in lightcraft_raw::semantic_masks(bytes) { - let Some(kind) = matte_kind(&m.name) else { continue }; - let Some(img) = m.developed(raw.active_area, raw.crop) else { continue }; - if kind != MatteKind::Sky && !img.data.iter().any(|&v| v >= 128) { - continue; - } - mattes.push(kind, img.into_oriented(raw.orientation)); - } - (!mattes.is_empty()).then(|| Arc::new(mattes)) -} - -/// What a semantic mask selects, by its `SemanticName`: Apple's (iPhone ProRAW) are named after -/// their auxiliary image types, `urn:com:apple:photo::aux:`. Other names: `None`. -fn matte_kind(name: &str) -> Option { - use lightcraft_pipeline::masks::MatteKind::*; - let kind = name.strip_prefix("urn:com:apple:photo:")?.rsplit(':').next()?; - Some(match kind { - "semanticskymatte" => Sky, - "semanticskinmatte" => Skin, - "semantichairmatte" => Hair, - "semanticteethmatte" => Teeth, - "semanticglassesmatte" => Glasses, - "portraiteffectsmatte" => Person, - _ => return None, - }) -} - -/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the -/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white -/// the camera tone's shoulder continues it. -pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option { - let knots: [[f32; 2]; 32] = std::array::from_fn(|i| { - let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0); - [x, curve.eval(x).min(0.9995)] - }); - lightcraft_pipeline::tone::CameraTone::new(knots) -} - -/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. -fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { - if jpeg_orientation > 1 { - return Orientation::from_exif(jpeg_orientation); - } - lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) -} - -/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is -/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first -/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image -/// bytes for [`lightcraft_codecs::decode`]. -fn stand_in_image(bytes: &[u8], covering: Option) -> Option> { - let jpeg = match covering { - Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)), - None => lightcraft_raw::embedded_preview(bytes), - }; - if let Some(jpeg) = jpeg { - return Some(std::borrow::Cow::Owned(jpeg)); - } - let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported()); - (undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff)) - .then_some(std::borrow::Cow::Borrowed(bytes)) -} - -/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see -/// [`stand_in_image`]). -pub(crate) fn decode_raw_preview( - bytes: &[u8], - opts: lightcraft_codecs::DecodeOptions, - covering: Option, -) -> Option { - let jpeg = stand_in_image(bytes, covering)?; - // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview - // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders - let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); - match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { - Some(space) => { - let space = match space { - lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, - lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb, - }; - lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok() - } - None => lightcraft_codecs::decode(&jpeg, opts).ok(), - } -} - -/// Oriented size of the embedded preview of a raw file. -fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { - let jpeg = stand_in_image(bytes, None)?; - // headers only, as for other images (issue #367): decoding a full-size preview just for its - // size took about a second per file in an import; a decode remains the fallback - let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) { - Ok(header) => (header.width, header.height, header.orientation), - Err(_) => { - let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; - (d.source_width, d.source_height, d.orientation) - } - }; - if preview_orientation(bytes, orientation).swaps_axes() { - std::mem::swap(&mut w, &mut h); - } - Some((w, h)) -} - -/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented. -pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> { - // always the largest JPEG: a source's geometry must not depend on its size level (previews match exports) - let d = decode_raw_preview(bytes, fit_box(max_edge), None)?; - let img = d.to_working(); - let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) -} - -/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the -/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest -/// embedded JPEG that covers `max_edge` is decoded, not the largest. -pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { - let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?; - if d.image.width.max(d.image.height) > max_edge { - d.image = fit(&d.image, max_edge, max_edge, Filter::Box); - d.alpha = None; - } - let o = preview_orientation(bytes, d.orientation); - Some(d.to_srgb8().oriented(o)) -} - -/// Decode options fitting into a `max_edge` square; `usize::MAX` (a full-size load) or any edge past -/// `u32::MAX` asks for the largest box rather than wrapping to a small one. -fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions { - let e = u32::try_from(max_edge).unwrap_or(u32::MAX); - lightcraft_codecs::DecodeOptions::fit(e, e) -} - -/// Filesystem-backed embedded-preview hook (native). -/// -/// The quick path reads the file's header and its embedded preview and nothing else (see -/// [`preview_window`]); a container the header cannot point into is planned from its tags -/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a -/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts -/// the whole file, which is what the decode of one can hold. -pub fn fs_preview_loader() -> PreviewLoader { - Arc::new(|path: &str, max_edge: usize| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) }; - if let Some(image) = preview_window(path, max_edge) - .ok() - .flatten() - .and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge)) - { - return Some(image); - } - embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge) - }) -} - -/// What [`preview_window`] read from a file, so a caller can see what a preview cost. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PreviewRead { - /// Bytes read. - pub read: u64, - /// The file's size. - pub total: u64, - /// Whether the preview came from a bounded read (`false`: the whole file was read, which is what - /// every preview cost before the header path existed). - pub header_path: bool, - /// Whether the sparse planner named the ranges, rather than a format's own header fields: the - /// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on - /// the header path, and on the whole-file fallback). - pub planned: bool, - /// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise - /// (`decode_raw_preview` applies the same declaration): the quick decode applies it too. - pub adobe_rgb: bool, -} - -impl PreviewRead { - /// The share of the file that was read, `0.0`..=`1.0`. - pub fn fraction(self) -> f64 { - if self.total == 0 { - return 0.0; - } - (self.read as f64 / self.total as f64).clamp(0.0, 1.0) - } -} - -/// The file's header and its embedded preview, read without the rest of the file: `(header bytes, -/// preview bytes, what was read)`. `None` when the header points at no preview this build can find, -/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps -/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest -/// that covers it ([`lightcraft_raw::embedded_preview_covering`]). -/// -/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`), -/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten -/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview` -/// still needs the whole container, so the header comes back with the window; for a preview taken -/// out of a container that carries no preview colour space of its own (Nikon's maker note) the -/// whole-file fallback stays in charge. -pub fn preview_window(path: &str, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { - use std::io::{Read, Seek, SeekFrom}; - - let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; - let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); - let mut prefix = read_prefix(&mut file, path, total)?; - - // What the header itself declares, before the container reader is asked: it clamps a slice to - // the buffer it was given, so a prefix that stops inside the declared preview yields a - // self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF: - // a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent - // first costs nothing — it lives in the first bytes of every format covered here. - if let Some(extent) = raf_preview_extent(&prefix) - && extent > prefix.len() as u64 - { - prefix = read_prefix_at(&mut file, path, extent.min(total))?; - } - let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { - // The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose - // preview tag sits past the prefix, the containers whose preview only a scan finds: ask the - // sparse planner which ranges `embedded_preview` reads before handing the caller the - // whole-file fallback. - return planned_preview(&mut file, path, total, max_edge); - }; - // A preview at the file's end may have been padded past it: clamp rather than fail. - let end = range.offset.saturating_add(range.window_len).min(total); - let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?; - // When the prefix already covers the range (the usual case: the prefix was grown to the declared - // extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview. - let prefix_len = prefix.len() as u64; - let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec); - let (window, bytes_read) = match from_prefix { - Some(window) => (window, prefix_len), - None => { - let mut window = vec![0u8; len]; - file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?; - file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?; - (window, prefix_len + len as u64) - } - }; - - // A lying pointer must not cost the user their preview: hand the caller the whole-file fallback. - if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() { - return Ok(None); - } - let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false }; - // On a network share the bytes are the wait, so say what this preview actually cost. A window - // taken from the prefix cost nothing beyond the prefix itself. - if lightcraft_pipeline::profiling() { - eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len()); - } - Ok(Some((prefix, window, read))) -} - -/// The most times the sparse planner may name ranges before the preview gives up: the plan is -/// monotone (it never asks for a range twice), so a real file needs a handful. -const MAX_PREVIEW_PLAN_ROUNDS: usize = 8; -/// Tenths of a file the planner may have read before the preview gives up and the whole-file path -/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the -/// file has stopped being one. -const MAX_PREVIEW_PLAN_SHARE: u64 = 6; -/// The largest file the planner path builds its buffer for. The raws it serves are far below this; -/// a file past it is read whole as before rather than allocating its length up front. -const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30; -/// The header the quick path is handed on the planner route. The header route grows its prefix to -/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out -/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough -/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes. -const PLAN_PREFIX: u64 = 64 * 1024; - -/// The preview of a container the header-only path cannot answer for, from the byte ranges -/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads. -/// -/// The planner is asked about a buffer of the file's whole length in which only the ranges named so -/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The -/// buffer is the file's length, which the loader's memory gate already counts, and its untouched -/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in -/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so -/// this path's cost is the bytes read, not a promise about RSS. -/// -/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the -/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it -/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when -/// the quick path could not reproduce what the whole-file path shows (the two container facts -/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded -/// gives what the whole file gives), so a window that still fails to decode ends in the same -/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture. -fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { - use std::io::{Read, Seek, SeekFrom}; - - if total > MAX_PREVIEW_PLAN_BYTES { - return Ok(None); - } - let Ok(len) = usize::try_from(total) else { return Ok(None) }; - let mut buf = vec![0u8; len]; - let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?; - let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) }; - head.copy_from_slice(&header); - let mut loaded = lightcraft_raw::sparse::Ranges::new(); - loaded.insert(0, header.len() as u64); - for _ in 0..MAX_PREVIEW_PLAN_ROUNDS { - match lightcraft_raw::sparse::preview_plan(&buf, &loaded) { - lightcraft_raw::sparse::HeaderPlan::Need(ranges) => { - let wanted = loaded.total().saturating_add(ranges.total()); - if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) { - return Ok(None); - } - for &(start, end) in ranges.spans() { - let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) }; - let Some(dst) = buf.get_mut(a..b) else { return Ok(None) }; - file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?; - file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?; - loaded.insert(start, end); - } - } - lightcraft_raw::sparse::HeaderPlan::Ready => { - // the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick - let edge = u32::try_from(max_edge).unwrap_or(u32::MAX); - let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) }; - // The quick path hands the JPEG to the decoder alone, so what the *container* says - // about the preview has to be something the bytes it is handed can show. Two container - // facts change the pixels and are not in the JPEG: the preview's colour space (which - // the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries - // no orientation of its own, the file's EXIF orientation. The colour space is read - // here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews, - // which used to send each of them to the whole-file path, issue #1440); a container - // that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB). - // Where the orientation is present and the header cannot answer for it, the - // whole-file path keeps the preview. - let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb); - // `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side - // first: a JPEG that carries its own orientation needs nothing from the container. - if lightcraft_meta::extract(&window).orientation.is_none() - && lightcraft_meta::extract(&header).orientation.is_none() - && lightcraft_meta::extract(&buf).orientation.is_some() - { - return Ok(None); - } - let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb }; - // A plan-driven preview is the one that used to cost the whole file, so it is worth - // saying what it cost now too. - if lightcraft_pipeline::profiling() { - eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len()); - } - // The planner's buffer is the file's length, so give its pages back the way a decode - // does (see `fs_pair_loader`). - drop(buf); - crate::memory::release(); - return Ok(Some((header, window, read))); - } - lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None), - } - } - Ok(None) -} - -/// 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. -fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result, String> { - let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX)); - read_prefix_at(file, path, len as u64) -} - -/// The file's first `len` bytes, read from the start. -fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result, String> { - use std::io::{Read, Seek, SeekFrom}; - let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?; - let mut prefix = vec![0u8; len]; - file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?; - file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?; - Ok(prefix) -} - -/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at -/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here -/// without the rest of the file so the prefix can be made big enough first. -fn raf_preview_extent(prefix: &[u8]) -> Option { - if !prefix.starts_with(b"FUJIFILMCCD-RAW") { - return None; - } - let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from); - let (offset, len) = (u32_at(84)?, u32_at(88)?); - (offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten() -} - -/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of -/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container. -/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied -/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image. -fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option { - let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) { - lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb) - } else { - lightcraft_codecs::decode(window, fit_box(max_edge)) - }; - let mut d = decoded.ok()?; - if d.image.width.max(d.image.height) > max_edge { - d.image = fit(&d.image, max_edge, max_edge, Filter::Box); - d.alpha = None; - } - // The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does - Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation))) -} - -/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's -/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load. -pub fn fs_pair_loader() -> PairLoader { - Arc::new(|path: &str, max_edge: usize, spec: &DenoiseSpec| { - let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); - let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 4 } else { 10 }; - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let r = load_vec_pair(bytes, max_edge, spec); - crate::memory::release(); - r - }) -} - -/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead. -/// -/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid -/// thumbnails, neighbour prefetch: [`crate::memory::in_background`]) and import probes wait while -/// too much is in flight, each counting its file's size times the expansion of decoding it; -/// interactive loads (the loupe, exports) never wait — they are counted, so background work -/// yields to them. -pub fn fs_hooks() -> (FileLoader, FileProbe) { - let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); - let probe: FileProbe = Arc::new(|path: &str| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let _permit = crate::memory::work_gate().acquire(len); - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let mut info = probe_bytes(path, &bytes)?; - info.file_modified = file_modified(path); - Ok(info) - }); - (loader, probe) -} - -/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader. -pub fn fs_reduced_loader() -> ReducedLoader { - Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge))) -} - -/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains. -fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result) -> Result { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }); - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let r = load(bytes); - // the file, the samples and the intermediate images are gone: give their pages back - crate::memory::release(); - r -} - -/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the -/// filesystem doesn't say or the time is before 1970. -fn file_modified(path: &str) -> Option { - let t = std::fs::metadata(path).ok()?.modified().ok()?; - let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; - Some(crate::import::civil(secs)) -} - -impl crate::Session { - /// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos - /// runner ([`crate::apple_photos::osascript`]; installed, not run). - pub fn with_fs(mut self) -> Self { - let (l, p) = fs_hooks(); - self.media.file_loader = Some(l); - self.media.reduced_loader = Some(fs_reduced_loader()); - self.media.file_probe = Some(p); - self.media.preview_loader = Some(fs_preview_loader()); - self.media.denoise.loader = Some(fs_pair_loader()); - #[cfg(target_os = "macos")] - { - self.apple_photos = Some(crate::apple_photos::osascript()); - } - self - } -} - #[cfg(test)] -pub(crate) mod tests { - /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the - /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides - /// them; an exported `travel|Italy|Rome` comes back as itself. - #[test] - fn a_lens_without_a_name_is_shown_by_its_range() { - // a Nikon DSLR raw: no LensModel, the range from the maker note - let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; - assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); - let named = lightcraft_meta::Metadata { - lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), - lens_spec: Some([24.0, 70.0, 4.0, 4.0]), - ..Default::default() - }; - assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); - assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); - } - - #[test] - fn imported_keywords_keep_their_hierarchy() { - let m = lightcraft_meta::Metadata { - keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], - hierarchical_keywords: vec!["travel|Italy|Rome".into()], - ..Default::default() - }; - assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); - } - - #[test] - fn psd_probe_reads_embedded_capture_time_gps_and_title() { - let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); - let metadata = lightcraft_meta::Metadata { - capture_time: Some(capture), - gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), - title: Some("PSD metadata".into()), - ..Default::default() - }; - let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); - let probe = probe_bytes("metadata.psd", &bytes).unwrap(); - assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); - assert_eq!(probe.meta.gps, Some((45.0, 7.0))); - assert_eq!(probe.meta.title, "PSD metadata"); - } - - use super::*; - use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; - - /// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a - /// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot - /// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the - /// format and camera string agrees. - #[test] - fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { - use lightcraft_raw::{ColorData, RawFormat}; - let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; - let mul = Some([2.2f32, 1.0, 1.6]); - let d65 = lightcraft_color::D65; - // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe - let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); - assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); - let (t, tint) = a7m3.as_shot(d65); - assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); - // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) - let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); - assert!(bare.legacy_relative && bare.scale_white.is_none()); - assert_eq!(bare.as_shot(d65), { - let (t, tint) = xy_to_temp_tint(d65); - (t.round(), tint.round()) - }); - // a camera without measured matrices: relative, whatever the multipliers - let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); - assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); - assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); - assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); - // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as - // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) - let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; - let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ - (2500.0, g(1336.0, 3672.0)), - (3200.0, g(1720.0, 2620.0)), - (4500.0, g(2256.0, 1880.0)), - (6000.0, g(2680.0, 1544.0)), - (8500.0, g(3136.0, 1300.0)), - ]); - let with = ColorData { kelvin_presets: presets, ..Default::default() }; - let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); - assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); - let (t, tint) = a99m2.as_shot(d65); - assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); - // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a - // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) - let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ - (5651.0, g(1418.0, 1357.0)), - (7505.0, g(1653.0, 1171.0)), - (6594.0, g(1533.0, 1259.0)), - (3174.0, g(963.0, 2016.0)), - (8529.0, g(1807.0, 1080.0)), - ]); - let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; - let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); - assert!(!g1x3.relative && g1x3.scale_white.is_some()); - let (t, tint) = g1x3.as_shot(d65); - assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); - let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); - assert!(bare.legacy_relative && bare.scale_white.is_none()); - // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone - let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); - assert!(other.relative && other.scale_white.is_none()); - let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); - assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); - // a file with matrices of its own (DNG): absolute from them, no matter whose camera - let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; - let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); - assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); - let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); - assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); - assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); - } - - /// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source - /// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative - /// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets - /// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. - #[test] - fn a_merged_dng_shows_the_white_balance_its_source_shows() { - use lightcraft_raw::{ColorData, RawFormat}; - let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; - let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; - let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ - (2500.0, g(1336.0, 3672.0)), - (3200.0, g(1720.0, 2620.0)), - (4500.0, g(2256.0, 1880.0)), - (6000.0, g(2680.0, 1544.0)), - (8500.0, g(3136.0, 1300.0)), - ]); - let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; - let cases = [ - (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), - (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), - (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), - (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), - ]; - for (format, color, mul, meta) in cases { - let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); - let source = WbScaleOf::new(format, &color, Some(mul), &meta); - let t = lightcraft_raw::color::camera_transform_of(&color, own); - let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); - let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); - let shown = source.as_shot(own); - assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); - } - } - - /// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. - fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec { - let head = 160; - let mut f = vec![0u8; head]; - f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW "); - f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes()); - f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes()); - f.extend_from_slice(jpeg); - f.extend(std::iter::repeat_n(0x33u8, tail)); - f - } - - /// A raw's preview is read from its header and its JPEG only. Every preview used to cost a - /// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten - /// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the - /// quick path produces must equal the whole-file path's, exactly. - #[test] - fn preview_loading_reads_only_the_header_and_the_jpeg() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let file = raf_with_embedded_jpeg(&jpeg, 200_000); - let dir = crate::tests_xmp::temp_dir("preview-window"); - let path = dir.join("dscf0001.raf"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - let max_edge = 512; - - let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview"); - assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); - assert!(read.header_path, "the header path is what answered"); - assert!(!read.planned, "a RAF pointer in the header needs no planner"); - assert_eq!(read.total, file.len() as u64); - assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); - assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); - - // The quick path and the whole-file path agree byte for byte. - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); - assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); - assert_eq!(quick.data, whole.data, "byte for byte"); - - // A file with no locatable preview declines (the caller then reads it whole), a missing file errors. - let plain = dir.join("plain.jpg"); - std::fs::write(&plain, &jpeg).unwrap(); - assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none()); - assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the - /// file's own orientation, and the EXIF colour space the container declares for its preview. - #[derive(Clone, Copy, Default)] - struct Fixture { - dng: bool, - sony: bool, - orientation: Option, - /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. - color_space: Option, - } - - /// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the - /// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of - /// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than - /// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. - fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { - tiff_with_preview_strips(strip, &[], tail, what) - } - - /// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its - /// 1616 px preview beside the full-size one. - fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut preview = IfdBuilder::new(); - if what.dng { - preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - } - if what.sony { - preview.set(t::MAKE, Value::Ascii("SONY".into())); - } - if let Some(o) = what.orientation { - preview.set(t::ORIENTATION, Value::Short(vec![o])); - } - if let Some(space) = what.color_space { - let mut exif = IfdBuilder::new(); - exif.set(0xA001, Value::Short(vec![space])); - if space == 0xFFFF { - let mut interop = IfdBuilder::new(); - interop.set(0x0001, Value::Undefined(b"R03".to_vec())); - exif.set_child(t::INTEROP_IFD, interop); - } - preview.set_child(t::EXIF_IFD, exif); - } - preview.set(t::COMPRESSION, Value::Short(vec![7])); - preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); - preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); - preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); - preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); - for jpeg in smaller { - let mut sub = IfdBuilder::new(); - sub.set(t::COMPRESSION, Value::Short(vec![7])); - sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); - sub.set(t::IMAGE_WIDTH, Value::Long(vec![48])); - sub.set(t::IMAGE_LENGTH, Value::Long(vec![32])); - sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] }); - preview.add_sub_ifd(sub); - } - let mut raw = IfdBuilder::new(); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); - raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); - TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") - } - - /// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview - /// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges - /// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels - /// must equal the whole-file path's, exactly; a file the planner cannot describe must decline - /// rather than guess, so the caller keeps reading it whole. - #[test] - fn preview_loading_plans_the_ranges_it_needs() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan"); - let path = dir.join("preview.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - let max_edge = 512; - - let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview"); - assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); - assert!(read.planned, "the planner is what answered"); - assert!(read.header_path, "and it is still a bounded read"); - assert_eq!(read.total, file.len() as u64); - assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); - assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); - - // The plan path and the whole-file path agree byte for byte. - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); - assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); - assert_eq!(quick.data, whole.data, "byte for byte"); - - // The same structure without the tag that makes it a raw: the planner declines, and the - // caller's whole-file read is what answers (no guessing at what a file might hold). - let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); - std::fs::write(&path, ¬_a_raw).unwrap(); - assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that - /// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an - /// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one. - #[test] - fn preview_loading_plans_the_smallest_covering_jpeg() { - let jpeg = |w: u32, h: u32| { - let rgb: Vec = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect(); - encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap() - }; - let (big, small) = (jpeg(96, 64), jpeg(48, 32)); - assert!(big.len() > small.len(), "the full-size JPEG is the largest"); - let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan-covering"); - let path = dir.join("two-previews.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - - for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] { - let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview"); - assert!(read.planned, "the planner is what answered"); - assert!(&window == want, "a {edge} px box takes the {which} JPEG"); - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too"); - assert_eq!( - (quick.width, quick.height, quick.data), - (whole.width, whole.height, whole.data), - "{edge} px: the plan and the whole file agree" - ); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// A file whose preview the quick path could not show the way the whole-file path does is left to - /// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is - /// bigger than that header) and the preview JPEG carries none of its own, so a window read would - /// come out unrotated where the whole-file path rotates it — the plan must decline instead of - /// handing on a wrong picture. - #[test] - fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts - // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image - let mut strip = jpeg.clone(); - strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); - let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); - let path = dir.join("rotated.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - - assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); - assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); - assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); - assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A container can declare what its preview JPEG is in, and the whole-file path applies that - /// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies - /// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and - /// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one - /// that declares sRGB (what most bodies write) keeps the fast path as before. - #[test] - fn preview_loading_applies_a_container_colour_space() { - use lightcraft_raw::PreviewColorSpace; - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let dir = crate::tests_xmp::temp_dir("preview-plan-space"); - let path = dir.join("space.arw"); - let path = path.to_string_lossy().into_owned(); - - let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); - assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); - std::fs::write(&path, &adobe).unwrap(); - let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too"); - assert!(read.planned && read.adobe_rgb, "{read:?}"); - assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}"); - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes"); - let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too"); - assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it"); - let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes"); - assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)"); - - let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); - assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); - std::fs::write(&path, &srgb).unwrap(); - let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); - assert!(read.planned && !read.adobe_rgb, "the plan still answers"); - assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); - } - - /// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as - /// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. - #[test] - fn undated_files_take_the_file_date() { - let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let rgb = vec![128u8; 16 * 16 * 3]; - let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let path = dir.join("export.jpg"); - std::fs::write(&path, &jpeg).unwrap(); - let path = path.to_string_lossy().to_string(); - let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let want = crate::import::civil(mtime as i64); - let (_, probe) = fs_hooks(); - let info = probe(&path).unwrap(); - assert_eq!(info.captured, None, "the file itself has no date"); - assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); - let mut s = crate::Session::new().with_fs(); - let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); - let id = lightcraft_catalog::PhotoId(r.imported[0]); - assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: - /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated - /// file or one that isn't an image. - #[test] - fn image_probe_reads_headers_like_the_decode_did() { - let (w, h) = (300u32, 200u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - for o in [None, Some(1), Some(6), Some(3), Some(8)] { - let exif = o.map(lightcraft_codecs::exif::minimal_exif); - let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); - let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); - for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { - let p = probe_bytes(name, bytes).unwrap(); - // what the probe computed from a decode before - let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); - let swap = Orientation::from_exif(d.orientation).swaps_axes(); - let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; - assert_eq!((p.width, p.height), want, "{name} {o:?}"); - assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); - assert_eq!(p.kind, MediaKind::Image); - assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); - assert_eq!(p.file_size, bytes.len() as u64); - assert!(p.content_hash.is_some()); - assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); - } - } - assert!(probe_bytes("x.jpg", b"not an image").is_err()); - } - - /// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets - /// start from), not on the black its transparent pixels store. - #[test] - fn transparent_png_develops_on_white() { - let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; - let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); - let (img, _) = load_bytes(&png, 64).unwrap(); - assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); - assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); - } - - /// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample - /// of a block on a grey surface must not become green speckles (the block's green used to take the - /// clip level, which highlight reconstruction then kept). - #[test] - fn coarse_binning_keeps_specular_points_neutral() { - let (w, h) = (256usize, 192usize); - // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right - let neutral = [0.5f32, 1.0, 0.7]; - let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); - let mut data: Vec = (0..w * h) - .map(|i| { - let (x, y) = (i % w, i / w); - let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; - (256.0 + v * (16000.0 - 256.0)).round() as u16 - }) - .collect(); - // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block - for by in 0..h / 8 { - for bx in (by % 2..w / 8).step_by(2) { - data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; - } - } - let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); - let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(bin_factor(&raw, 32), Some(8)); - let (img, _) = load_bytes(&bytes, 32).unwrap(); - assert_eq!((img.width, img.height), (32, 24)); - let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); - assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); - } - - #[test] - fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { - use lightcraft_denoise::product; - use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng}; - let dir = crate::tests_xmp::temp_dir("denoise-color-finish"); - let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&fixture).unwrap(); - raw.orientation = Orientation::Rotate90; - raw.color.baseline_exposure = 0.5; - raw.color.profile.look_table = - Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false }); - let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap(); - let decoded = lightcraft_raw::decode(&bytes).unwrap(); - assert!(decoded.color.profile.look_table.is_some()); - // A cache containing the ordinary demosaic must undergo the identical downstream finish. - let camera_rgb = decoded.develop(Method::Ahd).unwrap(); - let key = "synthetic-color-finish"; - let path = dir.join("same.lcdn"); - product::write(&path, &camera_rgb, key).unwrap(); - let spec = DenoiseSpec { product: path, key: key.into(), make: None }; - let (ordinary, _) = load_bytes(&bytes, 2048).unwrap(); - let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap(); - assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path"); - let cached = cached.unwrap(); - assert_eq!((plain.width, plain.height), (24, 32)); - assert_eq!((cached.width, cached.height), (plain.width, plain.height)); - let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); - assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}"); - let _ = std::fs::remove_dir_all(dir); - } - - /// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG - /// mostly cancel, edges remain. - fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { - let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); - let mut out = vec![0.0; w * h]; - for y in 0..h { - for x in 0..w { - let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); - let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); - out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; - } - } - out - } - - /// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its - /// normalised cross-correlation, or `None` when the patch has too little detail to tell. - fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { - let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); - let na = pa.iter().map(|v| v * v).sum::().sqrt(); - if na < 1e-3 * p as f32 { - return None; - } - let mut best = (f32::MIN, (0, 0)); - for dy in -r..=r { - for dx in -r..=r { - let (bx, by) = (x as i64 + dx, y as i64 + dy); - let (mut dot, mut nb) = (0.0f32, 0.0f32); - for i in 0..p { - for j in 0..p { - let v = b[(by as usize + i) * w + bx as usize + j]; - dot += pa[i * p + j] * v; - nb += v * v; - } - } - let c = dot / (na * nb.sqrt()).max(1e-12); - if c > best.0 { - best = (c, (dx, dy)); - } - } - } - Some((best.1, best.0)) - } - - #[test] - fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { - use lightcraft_geom::{Orientation, Point}; - use lightcraft_pipeline::optics::Warp; - let root = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); - for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { - let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; - let off = lightcraft_raw::probe_info(&bytes).unwrap(); - assert!(embedded_lens(&off).is_none()); - let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); - let at = tiff.find(0x7036).unwrap().offset as usize; - let mut enabled = Vec::new(); - tiff.order.put_u16(&mut enabled, 17); - bytes[at..at + 2].copy_from_slice(&enabled); - let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); - raw.orientation = Orientation::Normal; - let mut settings = lightcraft_develop::DevelopSettings::default(); - settings.optics.lens_profile = true; - let base_lens = embedded_lens(&raw).unwrap(); - let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); - for orientation in 1..=8 { - let o = Orientation::from_exif(orientation); - raw.orientation = o; - let lens = embedded_lens(&raw).unwrap(); - for edge in [1200.0, 2560.0, 9504.0] { - let scale = edge / 9504.0; - let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; - let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); - for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { - let point = Point::new(x * 9504.0, y * 6336.0); - let source = base.corrected_to_source(point, 1); - let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); - let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); - let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); - assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); - } - } - } - } - } - - /// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the - /// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile - /// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide - /// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. - #[test] - fn corpus_rw2_distortion_matches_the_camera_jpeg() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { - lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); - return; - }; - assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); - assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); - - // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) - let files = [ - ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners - ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % - ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % - ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % - ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction - ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % - ]; - const EDGE: usize = 640; - const P: usize = 64; - const R: i64 = 12; - for (name, off_at_least) in files { - let bytes = std::fs::read(dir.join(name)).unwrap(); - let probed = probe_bytes(name, &bytes).unwrap(); - assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); - let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); - assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); - // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` - let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); - assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); - let mut s = lightcraft_develop::DevelopSettings::default(); - let render = |s: &lightcraft_develop::DevelopSettings| { - lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image - }; - let before = render(&s); - s.optics.lens_profile = true; - let after = render(&s); - let (w, h) = (after.width, after.height); - assert_eq!((before.width, before.height), (w, h)); - // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre - let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); - let (cw, ch) = (cam.width as f64, cam.height as f64); - let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; - let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); - let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); - let lin = |v: u8| (v as f32 / 255.0).powf(2.2); - let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { - let p = im.data[y * im.width + x]; - 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) - }; - let reference = detail(w, h, |x, y| { - let p = cam.data[y * w + x]; - 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] - }); - let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); - let m = R as usize + 4; - let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; - let mut measured = 0; - for c in corners { - let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; - measured += 1; - assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); - if off_at_least > 0 - && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) - { - let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; - assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); - } - } - assert!(measured >= 2, "{name}: only {measured} corners with detail"); - } - } - - /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. - pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec { - let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); - b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes()); - b.extend_from_slice(b"PRVW\0\0\0\0\0\x01"); - b.extend_from_slice(&(w as u16).to_be_bytes()); - b.extend_from_slice(&(h as u16).to_be_bytes()); - b.extend_from_slice(b"\0\x01"); - b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes()); - b.extend_from_slice(&jpeg); - b - } - - /// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: - /// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). - fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { - let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); - let mut craw = vec![0u8; 82]; - craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); - craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); - craw.extend(bx(b"CMP1", &[0xff; 4])); - let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; - stsd.extend(bx(b"CRAW", &craw)); - let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); - let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); - let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); - let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); - let preview = cr3_with_preview(w, h); - [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() - } - - /// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: - /// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). - #[test] - fn corrupt_cr3_falls_back_to_embedded_preview() { - let b = cr3_with_corrupt_raw(48, 32); - let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); - assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); - let p = probe_bytes("x.cr3", &b).unwrap(); - assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); - assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); - let (img, src) = load_bytes(&b, 24).unwrap(); - assert_eq!((img.width, img.height), (24, 16)); - assert!(!src.raw); - // other formats still report a corrupt file as an error - let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - dng.truncate(dng.len() / 2); - assert!(load_bytes(&dng, 24).is_err()); - } - - /// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is - /// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. - #[test] - fn dng_profile_look_is_applied() { - use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - let sat = |img: &Rgb32f| { - img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() - / img.data.len() as f32 - }; - let (before, info) = load_bytes(&plain, 64).unwrap(); - assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); - assert!(info.camera_tone.is_none()); - // a map that removes all saturation, and a tone curve - let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; - raw.color.profile = ProfileLook { - hue_sat_map: [Some(grey), None], - look_table: None, - tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]), - gain_table_map: None, - }; - let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (after, info) = load_bytes(&with, 64).unwrap(); - assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); - // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) - let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; - assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); - let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); - assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); - // a look table alone changes the render too (applied after exposure) - raw.color.profile = ProfileLook { - look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), - ..Default::default() - }; - let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); - assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); - } - - /// Corrections used while fitting a DNG's preview must not be baked into its editable source. - /// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. - #[test] - fn dng_preview_corrections_leave_the_render_source_uncorrected() { - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); - let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; - let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (before, expected) = load_bytes(&before, 64).unwrap(); - let (after, info) = load_bytes(&corrected, 64).unwrap(); - assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); - assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); - assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); - } - - /// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. - /// The source pixels stay uncorrected, including when the file is rotated. - #[test] - fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { - use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; - use lightcraft_raw::{Opcode, Orientation}; - let (w, h) = (384usize, 256usize); - let data = (0..w * h) - .map(|i| { - let (x, y) = ((i % w) as f32, (i / w) as f32); - let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; - let colour = match (i / w % 2, i % w % 2) { - (0, 0) => 1.2, - (1, 1) => 0.8, - _ => 1.0, - }; - 256 + (v * colour * 15744.0) as u16 - }) - .collect(); - let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - raw.color = lightcraft_raw::ColorData { - illuminant: [21, 0], - color_matrix: [Some(REC2020.from_xyz()), None], - as_shot_neutral: Some([1.0; 3]), - ..Default::default() - }; - let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - raw.opcodes.list3 = vec![ - Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, - Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, - ]; - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); - let to_srgb = REC2020.to_space(&SRGB); - let pixels: Vec = corrected - .data - .iter() - .flat_map(|p| { - let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); - let y = luminance_2020(p).max(1e-6); - let gain = (1.0 - (-3.0 * y).exp()) / y; - to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) - }) - .collect(); - let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); - let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); - for orientation in [Orientation::Normal, Orientation::Rotate90] { - raw.orientation = orientation; - let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - // Append a preview IFD to the little-endian DNG written above. - let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; - let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; - let next = root + 2 + 12 * count; - let start = file.len() as u32; - file[next..next + 4].copy_from_slice(&start.to_le_bytes()); - let jpeg_at = start + 2 + 4 * 12 + 4; - file.extend(4u16.to_le_bytes()); - for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { - file.extend(tag.to_le_bytes()); - file.extend(4u16.to_le_bytes()); - file.extend(1u32.to_le_bytes()); - file.extend(value.to_le_bytes()); - } - file.extend(0u32.to_le_bytes()); - file.extend(&jpeg); - let (source, info) = load_bytes(&file, 96).unwrap(); - let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); - let target = 1.0 - (-3.0f32 * 0.18).exp(); - assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); - let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); - assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); - } - } - - /// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's - /// own white-balanced neutral comes out yellow (blue 12 % low). - fn yellowing_look() -> lightcraft_color::Mat3 { - lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) - } - - /// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the - /// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. - #[test] - fn camera_look_in_lanes_matches_pixel_by_pixel() { - let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); - let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; - let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); - let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; - let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; - let img = Rgb32f::from_fn(37, 5, |x, y| { - let k = y * 37 + x; - if k % 5 == 0 { - [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] - } else { - [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] - } - }); - let mut lanes = img.clone(); - colour.apply(&mut lanes); - let m = colour.matrix.to_f32(); - for (p, got) in img.data.iter().zip(&lanes.data) { - assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); - } - } - - #[test] - fn clipped_white_renders_neutral_through_the_colour_model() { - use lightcraft_color::{LUMA_2020, Mat3}; - let look = yellowing_look(); - let white = clipped_white(&look); - let out = look.apply(white.map(f64::from)); - assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}"); - let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::(); - assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral"); - assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]); - // a model that can't be inverted, or only just, keeps the camera's neutral - assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // so does one whose white would overflow the rebuilt highlights (2.5e38 in red here) - assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // or move the camera's neutral implausibly far - assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite - for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] { - let w = clipped_white(&model); - let m = model.to_f32(); - let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]); - assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}"); - let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None }; - let mut img = Rgb32f::filled(4, 4, [1.0; 3]); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0)); - } - } - - /// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that - /// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral; - /// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it. - #[test] - fn clipped_highlights_stay_neutral_when_darkened() { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.4f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None }; - // a grey wall (camera neutral) with a fully clipped light and a light clipped in green only - let mut img = Rgb32f::from_fn(96, 64, |x, y| { - let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); - if d(28.0) < 14.0 { - [1.0; 3] - } else if d(70.0) < 12.0 { - [0.6 / wb[0], 1.0, 0.7 / wb[2]] - } else { - [0.3 / wb[0], 0.3, 0.3 / wb[2]] - } - }); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let info = SourceInfo { raw: true, ..Default::default() }; - let mut issue = lightcraft_develop::DevelopSettings::default(); - let l = &mut issue.light; - (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); - let mut h100 = lightcraft_develop::DevelopSettings::default(); - h100.light.highlights = -100.0; - for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] { - let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image; - let p = out.get(28, 32); - assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})"); - let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); - assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral"); - } - } - - /// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`) - /// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise - /// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last - /// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row. - fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], u8) { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.5f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; - let w = 1600usize; - let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; - let sensor = - Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); - let y = (rows.start + rows.end) / 2; - let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); - let mut img = sensor.clone(); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let mut s = lightcraft_develop::DevelopSettings::default(); - s.light.exposure = -2.0; - let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image; - let step = |x: usize| { - let (a, b) = (out.get(x - 1, y), out.get(x, y)); - (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) - }; - ([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0)) - } - - /// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards - /// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels - /// there. - #[test] - fn rendered_colour_is_continuous_where_the_first_channel_clips() { - let ([a, b, c], worst) = darkened_ramp(0..16, 16); - let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); - assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}"); - assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels"); - } - - /// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there - /// is no reliable colour around it. It still carries on across the first clip, however thin the line and however - /// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163]; - /// on 512 rows, from [225, 126, 153] to [218, 145, 151]). - #[test] - fn rendered_thin_line_is_continuous_where_the_first_channel_clips() { - let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); - for height in [32usize, 128, 512, 1024] { - for width in [1usize, 2, 4, 16] { - let top = (height - width) / 2; - let ([a, b, c], worst) = darkened_ramp(top..top + width, height); - let case = format!("{width} rows of {height}"); - assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}"); - assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); - } - } - } - - /// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1, - /// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale - /// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows - /// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the - /// rendered colour on either side of where red clips on the band's middle row, and the largest step between - /// neighbouring pixels along that row. - fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.5f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; - let (w, h) = (320usize, top + strip + band); - let sensor = Rgb32f::from_fn(w, h, |x, y| { - if y < top { - [0.8, 0.35, 0.3] - } else if y < top + strip { - [0.4, 0.95, 0.8] - } else { - [0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8] - } - }); - let y = top + strip + band / 2; - let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap(); - let mut img = sensor.clone(); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let mut s = lightcraft_develop::DevelopSettings::default(); - s.light.exposure = -2.0; - let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image; - let step = |x: usize| { - let (a, b) = (out.get(x - 1, y), out.get(x, y)); - (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) - }; - ([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0)) - } - - /// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The - /// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly - /// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then - /// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped - /// (without the coloured surface: [198, 195, 178] then [205, 181, 168]). - #[test] - fn rendered_colour_is_continuous_where_a_second_channel_clips() { - for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] { - let ([a, b], worst) = second_clip(top, strip, band); - let case = format!("{top} rows of colour, {strip} of rim, {band} of band"); - let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0); - assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips"); - assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); - } - } - - /// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in - /// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo - /// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them). - #[test] - fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { - lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); - return; - }; - let (img, _) = load_bytes(&bytes, 1600).unwrap(); - let (w, h) = (img.width, img.height); - let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p); - let chroma = |p: [f32; 3]| { - let s = (p[0] + p[1] + p[2]).max(1e-6); - [p[0] / s, p[2] / s] - }; - // the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame - let px = |f: f32, n: usize| (f * n as f32) as usize; - let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]); - let mut rows = 0; - for y in px(0.24, h)..px(0.34, h) { - let row = &img.data[y * w..(y + 1) * w]; - let sky = lum(row[px(0.2, w)]); - let dark: Vec = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect(); - let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue }; - let k = w as f32 / 6000.0; - let mean = |a: usize, b: usize| { - let n = (b - a) as f64; - (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) - }; - let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); - for (side, (n, f)) in - [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] - .into_iter() - .enumerate() - { - for c in 0..2 { - near[side][c] += n[c]; - far[side][c] += f[c]; - } - } - rows += 1; - } - assert!(rows > 50, "the tree was not found ({rows} rows)"); - for side in 0..2 { - let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max); - assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]); - } - } - - /// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the - /// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The - /// copy's samples are much brighter than the image's here, so a picture says which one it came from. - #[test] - fn exports_never_develop_a_dngs_reduced_copy() { - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - use serde_json::json; - let linear = |subfile: u32, w: u32, h: u32, sample: u16| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); - ifd - }; - // (the copy must cover a thumbnail source's 512 px) - let (w, h) = (1200u32, 600u32); - let mut main = linear(0, w, h, 1500); - main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); - main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("DJI".into())); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); - ifd0.add_sub_ifd(main); - ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); - let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); - let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; - let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; - - // the loaders: only the thumbnail's develops the copy, and only when it covers the size - let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); - assert!(used, "a thumbnail develops the copy"); - assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); - let (full, _) = load_vec(bytes.clone(), 512).unwrap(); - assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); - - let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("merge-pano.dng"); - std::fs::write(&path, &bytes).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); - // an export at a thumbnail's size, before any thumbnail - assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); - let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); - // the thumbnail develops the copy and its source stays in memory… - s.media.clear_sources(); - let thumb = s.thumb_job(id, 64).unwrap().run(); - assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); - let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); - s.accept(&thumb); - assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); - // …and an export after it still reads the full image - let job = s.export_job(id, 64, 64, space, depth).unwrap(); - assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); - let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); - assert!((again - export).abs() < 1.0, "{again} vs {export}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right - /// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky - /// heuristic would never look) is used; a matte with an unknown name is ignored. - #[test] - fn dng_sky_matte_drives_the_sky_mask() { - use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; - use lightcraft_pipeline::{RenderRequest, render}; - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - let (w, h) = (16u32, 8u32); - let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec| { - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] }); - }; - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); - image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize)); - let matte = |name: &str, right: bool| { - let mut m = IfdBuilder::new(); - m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK])); - m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK])); - m.set(t::SEMANTIC_NAME, Value::Ascii(name.into())); - image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect()); - m - }; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("Apple".into())); - ifd0.set(t::ORIENTATION, Value::Short(vec![6])); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); - image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]); - ifd0.add_sub_ifd(raw); - ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true)); - ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false)); - let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); - - let (img, info) = load_bytes(&bytes, 64).unwrap(); - assert_eq!((img.width, img.height), (8, 16)); - assert!(info.mattes.is_some()); - let sky = Mask { - components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], - adjust: LocalAdjustments { exposure: -3.0, ..Default::default() }, - ..Default::default() - }; - let s = DevelopSettings { masks: vec![sky], ..Default::default() }; - let req = RenderRequest::fit(8, 16); - let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32; - let plain = DevelopSettings::default(); - assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top"); - assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened"); - // without the matte the heuristic looks at the top of the frame instead - let heuristic = SourceInfo { mattes: None, ..info.clone() }; - assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2)); - assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13)); - } - - /// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the - /// pipeline the file's colour tags (without the profile look, applied at load) and the white - /// the pixels were developed for, and a custom white balance renders a neutral differently - /// from the Bradford adaptation it replaces. - #[test] - fn dng_white_balance_uses_the_camera_colour_model() { - use lightcraft_pipeline::{RenderRequest, render}; - let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let raw = lightcraft_raw::decode(&dng).unwrap(); - let (img, info) = load_bytes(&dng, 64).unwrap(); - let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix"); - assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw)); - assert!(cc.tags.profile.is_empty()); - assert_eq!(cc.tags.color_matrix, raw.color.color_matrix); - let mut s = lightcraft_develop::DevelopSettings::default(); - (s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0); - let req = RenderRequest::fit(32, 32); - let camera = render(&img, &info, &s, &req).image; - let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image; - assert_ne!(camera.data, adapted.data); - } - - /// Apple ProRAW: like Lightroom Classic, the default render ignores the file's - /// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel - /// changes nothing. - #[test] - fn dng_gain_table_map_is_not_rendered() { - use lightcraft_raw::gaintable::GainTableMap; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); - raw.color.profile.gain_table_map = Some(GainTableMap { - points_v: 1, - points_h: 1, - points_n: 1, - spacing_v: 1.0, - spacing_h: 1.0, - origin_v: 0.0, - origin_h: 0.0, - weights: [0.2, 0.2, 0.2, 0.2, 0.2], - gamma: 1.0, - gains: vec![2.0], - }); - let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept"); - let (after, _) = load_bytes(&with_map, 64).unwrap(); - assert_eq!(before.data, after.data); - } - - /// The Profile option "Camera local tone mapping" renders the map by position (in the raw's - /// active area, whatever the photo's orientation), and only when it's on. - #[test] - fn dng_gain_table_map_renders_when_asked() { - use lightcraft_pipeline::{RenderRequest, render}; - use lightcraft_raw::gaintable::GainTableMap; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - // ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour - raw.color.profile.gain_table_map = Some(GainTableMap { - points_v: 1, - points_h: 2, - points_n: 1, - spacing_v: 1.0, - spacing_h: 1.0, - origin_v: 0.0, - origin_h: 0.0, - weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], - gamma: 1.0, - gains: vec![1.0, 4.0], - }); - let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (img, info) = load_bytes(&with_map, 64).unwrap(); - let (_, plain_info) = load_bytes(&plain, 64).unwrap(); - assert!(info.local_tone.is_some() && plain_info.local_tone.is_none()); - let req = RenderRequest::fit(64, 64); - let mut s = lightcraft_develop::DevelopSettings::default(); - // (darker, so that ×4 never reaches white) - s.light.exposure = -3.0; - let off = render(&img, &info, &s, &req).image; - assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing"); - s.profile.camera_local_tone = true; - assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)"); - let on = render(&img, &info, &s, &req).image; - // brightness gained, per column: none at the left edge, most at the right - let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::(); - let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); - let last = on.width - 1; - assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0)); - assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last)); - // the photo turned upside down: the map follows the raw, so the brightened side swaps - s.orientation = lightcraft_geom::Orientation::Rotate180; - s.profile.camera_local_tone = false; - let off = render(&img, &info, &s, &req).image; - s.profile.camera_local_tone = true; - let on = render(&img, &info, &s, &req).image; - let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); - assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last)); - // a rendered (non-raw) source never gets it - let jpeg = SourceInfo { raw: false, ..info.clone() }; - assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s)); - } - - #[test] - fn unsupported_raw_falls_back_to_embedded_preview() { - let b = cr3_with_preview(48, 32); - let p = probe_bytes("x.cr3", &b).unwrap(); - assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); - let (img, src) = load_bytes(&b, 24).unwrap(); - assert_eq!((img.width, img.height), (24, 16)); - assert!(!src.raw); - // no preview at all: a clear error, not a panic - assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); - } - - /// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's - /// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a - /// library reopen, and Reload / Relink clear it once the file decodes (undoably). - #[test] - fn unsupported_raw_imports_as_preview_only_and_survives_reopen() { - use serde_json::json; - let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let (lib, files) = (dir.join("lib"), dir.join("files")); - std::fs::create_dir_all(&files).unwrap(); - let path = files.join("DSC_0001.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - // the import review already says so - let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}"); - let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - let p = s.catalog.photo(id).unwrap().clone(); - let why = p.preview_only.clone().expect("marked preview only"); - assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); - // stamped with the version that decided it, so a later version rechecks it (#662) - assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); - assert!(!p.preview_only_stale()); - assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); - assert!(!p.develops_raw()); - // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults - assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); - assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); - assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); - // agents see it - let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap(); - assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}"); - let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap(); - assert_eq!(i["preview_only"], json!(why), "{i}"); - // a library reopen keeps it - s.persist().unwrap(); - drop(s); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); - // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back - std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); - let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); - assert_eq!(r["reloaded"], json!([id.0]), "{r}"); - assert!(s.catalog.photo(id).unwrap().develops_raw()); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); - assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); - // relinking to a file that decodes clears it too - let dng = files.join("DSC_0001.dng"); - std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); - s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only, None); - drop(s); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif - /// declares a picture 25x its size. - fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); - ifd0.set_child( - t::EXIF_IFD, - IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), - ); - let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); - if shell { - b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); - } - b - } - - /// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size - /// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't - /// decode: preview only, with the reason, still showing that image. - #[test] - fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { - let b = tiff_shell(24, 16, true); - let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); - let why = p.preview_only.expect("marked preview only"); - assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); - let (img, src) = load_bytes(&b, 12).unwrap(); - assert_eq!((img.width, img.height, src.raw), (12, 8, false)); - assert!(embedded_preview_srgb(&b, 12).is_some()); - // the same pixels without the private block are a small ordinary image - let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); - assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); - assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); - - // TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281) - for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set(t::MAKE, Value::Ascii(make.into())); - let px: Vec = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] }); - let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); - let p = probe_bytes("photo.tif", &bytes).unwrap(); - assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}"); - let (img, src) = load_bytes(&bytes, 24).unwrap(); - assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}"); - } - } - - /// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first - /// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. - #[test] - fn undecodable_container_with_a_preview_imports_as_preview_only() { - let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - jpeg[0] = 0x02; - let mut f = b"\0MRM\0\x01\0\0".to_vec(); - f.extend(std::iter::repeat_n(0x11u8, 300)); - f.extend_from_slice(&jpeg); - f.extend(std::iter::repeat_n(0x22u8, 300)); - let p = probe_bytes("A.MRW", &f).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30)); - assert!(p.preview_only.is_some_and(|w| w.contains("Mrw"))); - let (img, src) = load_bytes(&f, 20).unwrap(); - assert_eq!((img.width, img.height, src.raw), (20, 15, false)); - // without a JPEG: a clear error - let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err(); - assert!(e.contains("without an embedded preview"), "{e}"); - } - - /// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` - /// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. - #[test] - fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - let mos = |leaf_data: bool| { - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); - if leaf_data { - ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); - } - TiffWriter::default().write(&[ifd0]).unwrap() - }; - let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); - assert!(p.preview_only.is_some_and(|why| !why.is_empty())); - let (img, src) = load_bytes(&mos(true), 20).unwrap(); - assert_eq!((img.width, img.height, src.raw), (20, 15, false)); - // the same grey image without Leaf's block is an ordinary 16-bit TIFF - let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); - assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); - } - - /// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its - /// headers; loading it says what is wrong instead of naming a bare TIFF compression number. - #[test] - fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); - ifd.set(t::COMPRESSION, Value::Short(vec![9])); - ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); - let f = TiffWriter::default().write(&[ifd]).unwrap(); - let p = probe_bytes("GOPR0001.GPR", &f).unwrap(); - assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16)); - let e = load_bytes(&f, 16).unwrap_err(); - assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}"); - } - - /// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; - /// CR3 keeps its own wording. - #[test] - fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() { - use lightcraft_raw::RawError; - let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01"; - assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string())); - assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip"))); - assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok()); - assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok()); - assert!(preview_reason(b"x", RawError::NotRaw).is_err()); - assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 "))); - } - - #[test] - fn quick_jobs_show_embedded_previews_then_cached_renders() { - use crate::media::QuickSource; - let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("a.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - // loupe: an unedited raw opens on its embedded preview… - let q = s.quick_view_job(id, 1600, true).unwrap().run(); - assert_eq!(q.quick, Some(QuickSource::Embedded)); - let img = q.rendered.unwrap().image; - assert_eq!((img.width, img.height), (96, 64)); - // …and once the loupe has rendered it, on that render (any size) - let full = s.loupe_job(id, 48, 32, true).unwrap().run(); - assert!(full.rendered.is_ok() && full.quick.is_none()); - let q = s.quick_view_job(id, 1600, true).unwrap().run(); - assert_eq!(q.quick, Some(QuickSource::Cached)); - assert_eq!(q.rendered.unwrap().image.width, 48); - // grid: embedded first, then the real thumbnail (final, with the thumbnail job's key) - let job = s.thumb_job(id, 128).unwrap(); - let q = s.quick_thumb_job(&job).unwrap().run(); - assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key)); - drop(job.clone().run()); - assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached)); - // an edited raw: no embedded stand-in (it wouldn't show the edit) - s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); - s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap(); - let job = s.thumb_job(id, 128).unwrap(); - assert!(s.quick_thumb_job(&job).is_none()); - assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the - /// embedded preview can't be read (and nothing is cached) the quick job itself must render the - /// raw, or the cell stays empty for good ("no quick preview"). - #[test] - fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() { - use crate::media::QuickSource; - let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("a.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None)); - let job = s.thumb_job(id, 128).unwrap(); - let q = s.quick_thumb_job(&job).unwrap().run(); - assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key)); - assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` - /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). - fn sony_arw(model: &str) -> Vec { - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - // 3:2, the aspect Sony's lens opcodes are limited to - let (w, h) = (72u32, 48u32); - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); - raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); - raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::MAKE, Value::Ascii("SONY".into())); - ifd0.set(t::MODEL, Value::Ascii(model.into())); - ifd0.add_sub_ifd(raw); - TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() - } - - /// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens - /// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. - #[test] - fn arw_with_lens_data_imports_with_profile_corrections_on() { - let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut s = crate::Session::new().with_fs(); - for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { - let path = dir.join(format!("{model}.arw")); - std::fs::write(&path, sony_arw(model)).unwrap(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); - assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); - assert!(!p.is_edited(), "{model}: as imported"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's - /// opcodes), off when it doesn't. - #[test] - fn rw2_and_dng_defaults_unchanged() { - use lightcraft_catalog::{Photo, PhotoId, Source}; - let s = crate::Session::new(); - let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; - for format in ["RW2", "DNG"] { - for embedded in [None, Some(lens)] { - let name = format!("x.{format}"); - let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); - p.kind = MediaKind::Raw; - p.embedded_lens = embedded; - crate::import::apply_import_defaults(&s, &mut p); - assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); - } - } - } - - /// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read - /// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the - /// correction off keeps it off. - #[test] - fn existing_photo_settings_are_not_rewritten() { - use lightcraft_catalog::{Op, PhotoId}; - let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let (lib, files) = (dir.join("lib"), dir.join("files")); - std::fs::create_dir_all(&files).unwrap(); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - let import = |s: &mut crate::Session, name: &str| { - let path = files.join(name); - // trailing bytes of its own: not a duplicate of the other file - std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - PhotoId(r["imported"][0].as_u64().unwrap()) - }; - let old = import(&mut s, "old.arw"); - // as a release that didn't read the Sony tables catalogued it - let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); - d.optics.lens_profile = false; - let ops = vec![ - Op::SetEmbeddedLens { id: old, lens: None }, - Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, - ]; - s.commit("x", Op::Batch { ops }).unwrap(); - let off = import(&mut s, "off.arw"); - s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); - let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); - assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); - s.persist().unwrap(); - drop(s); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - for b in &before { - let p = s.catalog.photo(b.id).unwrap(); - assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); - } - assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); - drop(s); - let _ = std::fs::remove_dir_all(&dir); - } -} +pub(crate) mod tests; diff --git a/crates/engine/src/files/colour.rs b/crates/engine/src/files/colour.rs new file mode 100644 index 000000000..cc005089a --- /dev/null +++ b/crates/engine/src/files/colour.rs @@ -0,0 +1,53 @@ +/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders +/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out +/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a +/// camera profile often tints it by a few per cent, which any darkening of the highlights would show). +/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not +/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per +/// cent), or one that isn't neutral through the `f32` matrix the decode applies. +pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] { + const CAMERA: [f32; 3] = [1.0; 3]; + /// Largest condition number (max row sum norm) of a model trusted for this. + const CONDITION: f64 = 1e3; + /// Largest factor between the answer and the camera's neutral, per channel. + const SPREAD: f64 = 4.0; + let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::()).fold(0.0, f64::max); + let Some(inverse) = model.inverse() else { return CAMERA }; + let condition = norm(model) * norm(&inverse); + if !condition.is_finite() || condition > CONDITION { + return CAMERA; + } + let luma = |v: [f64; 3]| { + let c = model.apply(v); + (0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::() + }; + let n = inverse.apply([1.0; 3]); + let (target, own) = (luma([1.0; 3]), luma(n)); + if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) { + return CAMERA; + } + let white = n.map(|v| v * target / own); + if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) { + return CAMERA; + } + let white = white.map(|v| v as f32); + // neutral through the matrix as the decode applies it + let m = model.to_f32(); + let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]); + let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2])); + if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA } +} + +/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels: +/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the +/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the +/// cost of Bayer's, so 3× is accepted down to 75 % (a 24 MP X-Trans preview is then ~2000 px +/// instead of a ~1.2 s full demosaic; zooming in still uses the full-size source). +/// `None` = demosaic at full size. +pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option { + let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + let long = if c.width > 1 && c.height > 1 { c.width.max(c.height) } else { raw.active_area.width.max(raw.active_area.height) }; + let need = (max_edge.saturating_mul(9) / 10).max(1); + let need3 = (max_edge.saturating_mul(3) / 4).max(1); + [8usize, 6, 4, 3, 2].into_iter().find(|&k| raw.can_bin(k) && (long / k >= need || (k == 3 && !raw.can_bin(2) && long / k >= need3))) +} diff --git a/crates/engine/src/files/fs.rs b/crates/engine/src/files/fs.rs new file mode 100644 index 000000000..dd1f5273c --- /dev/null +++ b/crates/engine/src/files/fs.rs @@ -0,0 +1,131 @@ +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::*; + +/// 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. +pub(super) fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result, String> { + let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX)); + read_prefix_at(file, path, len as u64) +} + +/// The file's first `len` bytes, read from the start. +pub(super) fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result, String> { + use std::io::{Read, Seek, SeekFrom}; + let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?; + let mut prefix = vec![0u8; len]; + file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?; + Ok(prefix) +} + +/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at +/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here +/// without the rest of the file so the prefix can be made big enough first. +pub(super) fn raf_preview_extent(prefix: &[u8]) -> Option { + if !prefix.starts_with(b"FUJIFILMCCD-RAW") { + return None; + } + let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from); + let (offset, len) = (u32_at(84)?, u32_at(88)?); + (offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten() +} + +/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of +/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container. +/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied +/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image. +pub(super) fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option { + let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) { + lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb) + } else { + lightcraft_codecs::decode(window, fit_box(max_edge)) + }; + let mut d = decoded.ok()?; + if d.image.width.max(d.image.height) > max_edge { + d.image = fit(&d.image, max_edge, max_edge, Filter::Box); + d.alpha = None; + } + // The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does + Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation))) +} + +/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's +/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load. +pub fn fs_pair_loader() -> PairLoader { + Arc::new(|path: &str, max_edge: usize, spec: &DenoiseSpec| { + let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); + let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 4 } else { 10 }; + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let r = load_vec_pair(bytes, max_edge, spec); + crate::memory::release(); + r + }) +} + +/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead. +/// +/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid +/// thumbnails, neighbour prefetch: [`crate::memory::in_background`]) and import probes wait while +/// too much is in flight, each counting its file's size times the expansion of decoding it; +/// interactive loads (the loupe, exports) never wait — they are counted, so background work +/// yields to them. +pub fn fs_hooks() -> (FileLoader, FileProbe) { + let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); + let probe: FileProbe = Arc::new(|path: &str| { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let _permit = crate::memory::work_gate().acquire(len); + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let mut info = probe_bytes(path, &bytes)?; + info.file_modified = file_modified(path); + Ok(info) + }); + (loader, probe) +} + +/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader. +pub fn fs_reduced_loader() -> ReducedLoader { + Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge))) +} + +/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains. +fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result) -> Result { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }); + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let r = load(bytes); + // the file, the samples and the intermediate images are gone: give their pages back + crate::memory::release(); + r +} + +/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the +/// filesystem doesn't say or the time is before 1970. +fn file_modified(path: &str) -> Option { + let t = std::fs::metadata(path).ok()?.modified().ok()?; + let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; + Some(crate::import::civil(secs)) +} + +impl crate::Session { + /// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos + /// runner ([`crate::apple_photos::osascript`]; installed, not run). + pub fn with_fs(mut self) -> Self { + let (l, p) = fs_hooks(); + self.media.file_loader = Some(l); + self.media.reduced_loader = Some(fs_reduced_loader()); + self.media.file_probe = Some(p); + self.media.preview_loader = Some(fs_preview_loader()); + self.media.denoise.loader = Some(fs_pair_loader()); + #[cfg(target_os = "macos")] + { + self.apple_photos = Some(crate::apple_photos::osascript()); + } + self + } +} diff --git a/crates/engine/src/files/load.rs b/crates/engine/src/files/load.rs new file mode 100644 index 000000000..52b01d7f7 --- /dev/null +++ b/crates/engine/src/files/load.rs @@ -0,0 +1,350 @@ +use super::colour::bin_factor; +use super::preview::{fit_box, load_embedded_preview}; +use super::probe::preview_reason; +use super::*; + +/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. +/// +/// Runs on a rayon worker: its many short parallel loops then start on the worker's own queue +/// instead of each one waking the pool from outside and waiting for it (which costs more than the +/// loops themselves when the machine is busy). +pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) +} + +/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded +/// (less memory held while it is developed). +pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) +} + +/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and +/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether +/// it did: such a picture is not the full image, and an export never renders from it. +pub fn load_vec_reduced(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced))) +} + +/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain +/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all +/// there is) and the source facts. Both pictures are the same size. +pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option, SourceInfo), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info))) +} + +/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default +/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the +/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when +/// the product is missing, damaged, made from something else or not the size of this raw's active area. +fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option { + use lightcraft_denoise::product::{self, Window}; + let a = raw.active_area; + let header = product::read_header_at(&spec.product).ok()?; + if header.key != spec.key || (header.width, header.height) != (a.width, a.height) { + return None; + } + if raw.opcodes.list3.is_empty() { + let c = raw.develop_crop(a.width, a.height); + let window = Window { x: c.x, y: c.y, width: c.width, height: c.height }; + product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok() + } else { + // (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1) + let whole = product::read(&spec.product, &spec.key, 1).ok()?; + Some(raw.finish_demosaiced(whole)) + } +} + +fn load_bytes_now( + bytes: std::borrow::Cow<'_, [u8]>, + max_edge: usize, + denoise: Option<&DenoiseSpec>, + allow_reduced: bool, +) -> Result<(Rgb32f, Option, SourceInfo, bool), String> { + if lightcraft_raw::probe(&bytes).is_some() { + // a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image + // (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic + let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX) + .then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()) + .flatten(); + let used_reduced = reduced.is_some(); + let decoded = match reduced { + Some(r) => Ok(r), + None => lightcraft_raw::decode(&bytes), + }; + let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) { + Ok(r) => r, + Err(Ok(why)) => { + // show the camera's embedded JPEG (rendered, not raw) until the variant is supported + return load_embedded_preview(&bytes, max_edge) + .map(|(img, info)| (img, None, info, false)) + .ok_or(format!("unsupported raw ({why}) without an embedded preview")); + } + Err(Err(e)) => return Err(e), + }; + // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in + // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). + let info = raw.info(); + let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); + // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) + let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); + // A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit, + // then remove them again: the rendered source leaves those corrections to the pipeline. + let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction())) + .then(|| raw.opcodes.list3.clone()); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or + // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted + let ((xy, t, camera_look), mattes) = rayon::join( + || { + let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops)); + // With the opcodes restored, the proxy already includes vignetting; do not apply it twice. + let embedded = if removed.is_some() { None } else { lens.as_ref() }; + let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded); + if let Some(ops) = removed { + raw.opcodes.list3 = ops; + } + colour + }, + || dng_mattes(&bytes, &info), + ); + drop(bytes); + // Previews and thumbnails bin the mosaic straight to (about) the size they need; only + // larger levels (exports, 1:1) demosaic the whole sensor. + let t0 = web_time::Instant::now(); + let bin = bin_factor(&raw, max_edge); + // (binned: block means, and which colours had a clipped sample in each block) + let binned = match bin { + Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, + None => None, + }; + let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; + let (img, clip_mask) = match binned { + Some((img, mask)) => (img, Some(mask)), + None => { + let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + (raw.develop(method).map_err(|e| e.to_string())?, None) + } + }; + // the same development from the denoised mosaic, when the photo has been denoised + let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height); + // the samples aren't needed any more (the colour model below reads only the tags) + raw.data = lightcraft_raw::RawData::U16(Vec::new()); + let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new); + // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. + let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); + let colour = CameraColourModel { + wb: t.wb, + matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix), + gain: 2f32.powf(t.baseline_exposure as f32), + tables: tables.as_ref(), + hue_sat: hue_sat.as_ref(), + }; + // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, + // fitted to `max_edge` and upright (the same for the plain and the denoised picture) + let finish = |mut img: Rgb32f| { + let mut stages = vec![("develop", t0.elapsed())]; + stages.push(("transform", t0.elapsed())); + match &clip_mask { + Some(mask) => colour.rebuild_highlights_masked(&mut img, mask), + None => colour.rebuild_highlights(&mut img), + } + stages.push(("highlights", t0.elapsed())); + colour.apply(&mut img); + stages.push(("colour", t0.elapsed())); + // a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP) + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img }; + stages.push(("fit", t0.elapsed())); + let img = img.into_oriented(raw.orientation); + stages.push(("orient", t0.elapsed())); + (img, stages) + }; + let (img, stages) = finish(img); + if lightcraft_pipeline::profiling() { + let mut prev = std::time::Duration::ZERO; + let parts: Vec = stages + .iter() + .map(|(n, t)| { + let d = *t - prev; + prev = *t; + format!("{n} {:.1}", d.as_secs_f64() * 1e3) + }) + .collect(); + eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); + } + let twin = twin.map(|picture| finish(picture).0); + // White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits + // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the + // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale + // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) + .then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); + let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); + let local_tone = local_tone(&raw); + let (temp, tint) = scale.as_shot(xy); + return Ok(( + img, + twin, + SourceInfo { + raw: true, + as_shot_temp: temp, + as_shot_tint: tint, + lens, + relative_wb: scale.relative, + legacy_relative_wb: scale.legacy_relative, + camera_color, + camera_tone, + mattes, + local_tone, + // (the catalog knows the original's size: `media::source_info`) + native_long: None, + }, + used_reduced, + )); + } + let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; + drop(bytes); + let img = d.to_working(); + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; + Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false)) +} + +/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from +/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step). +/// +/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without +/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured +/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one +/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the +/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). +pub(crate) struct WbScaleOf { + pub(super) own_matrix: bool, + pub(super) relative: bool, + /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. + pub(super) legacy_relative: bool, + /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. + pub(super) scale_white: Option, + /// The camera's measured matrices, when they give the scale. + camera: Option<&'static lightcraft_raw::spectral::Camera>, +} + +/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does +/// ([`WbScaleOf::dng_white`]). +pub(crate) enum DngWhite { + /// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does. + Matrices(Box), + /// The white the source's scale shows at As Shot. + At(lightcraft_color::Xy), +} + +impl WbScaleOf { + pub(crate) fn new( + format: lightcraft_raw::RawFormat, + color: &lightcraft_raw::ColorData, + wb_multipliers: Option<[f32; 3]>, + meta: &lightcraft_meta::Metadata, + ) -> Self { + let own_matrix = lightcraft_raw::color::has_matrix(color); + let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; + let model = meta.model.as_deref().filter(|_| fitted); + let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model)); + let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model)); + let scale_white = match camera { + Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers), + None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers), + None => None, + }; + let covered = camera.is_some() || presets; + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera } + } + + /// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the + /// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets' + /// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give). + pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite { + if let Some(camera) = self.camera { + let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() }; + if camera.fill(&mut c) { + return DngWhite::Matrices(Box::new(c)); + } + } + if self.relative { + return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); + } + DngWhite::At(self.scale_white.unwrap_or(own)) + } + /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through + /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the + /// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to + /// `own`, D65. + pub(super) fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { + if self.relative { + return (6500.0, 0.0); + } + let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own)); + (t.round(), tint.round()) + } +} + +/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in +/// the active area: rendered when the photo's "Camera local tone mapping" option is on. +fn local_tone(raw: &lightcraft_raw::RawImage) -> Option> { + let map = raw.color.profile.gain_table_map.clone()?; + let a = raw.active_area; + let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64); + let c = raw.develop_crop(a.width, a.height); + let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah]; + let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation }; + Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement })) +} + +/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop, +/// oriented like it). Person mattes without a confidently selected pixel are left out (the +/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky). +fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option> { + use lightcraft_pipeline::masks::{MatteKind, Mattes}; + if raw.format != lightcraft_raw::RawFormat::Dng { + return None; + } + let mut mattes = Mattes::default(); + for m in lightcraft_raw::semantic_masks(bytes) { + let Some(kind) = matte_kind(&m.name) else { continue }; + let Some(img) = m.developed(raw.active_area, raw.crop) else { continue }; + if kind != MatteKind::Sky && !img.data.iter().any(|&v| v >= 128) { + continue; + } + mattes.push(kind, img.into_oriented(raw.orientation)); + } + (!mattes.is_empty()).then(|| Arc::new(mattes)) +} + +/// What a semantic mask selects, by its `SemanticName`: Apple's (iPhone ProRAW) are named after +/// their auxiliary image types, `urn:com:apple:photo::aux:`. Other names: `None`. +fn matte_kind(name: &str) -> Option { + use lightcraft_pipeline::masks::MatteKind::*; + let kind = name.strip_prefix("urn:com:apple:photo:")?.rsplit(':').next()?; + Some(match kind { + "semanticskymatte" => Sky, + "semanticskinmatte" => Skin, + "semantichairmatte" => Hair, + "semanticteethmatte" => Teeth, + "semanticglassesmatte" => Glasses, + "portraiteffectsmatte" => Person, + _ => return None, + }) +} + +/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the +/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white +/// the camera tone's shoulder continues it. +pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option { + let knots: [[f32; 2]; 32] = std::array::from_fn(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0); + [x, curve.eval(x).min(0.9995)] + }); + lightcraft_pipeline::tone::CameraTone::new(knots) +} diff --git a/crates/engine/src/files/preview.rs b/crates/engine/src/files/preview.rs new file mode 100644 index 000000000..49430e62a --- /dev/null +++ b/crates/engine/src/files/preview.rs @@ -0,0 +1,320 @@ +use super::fs::{quick_image_srgb, raf_preview_extent, read_prefix, read_prefix_at}; +use super::*; + +/// 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 { + if jpeg_orientation > 1 { + return Orientation::from_exif(jpeg_orientation); + } + lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) +} + +/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is +/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first +/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image +/// bytes for [`lightcraft_codecs::decode`]. +fn stand_in_image(bytes: &[u8], covering: Option) -> Option> { + let jpeg = match covering { + Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)), + None => lightcraft_raw::embedded_preview(bytes), + }; + if let Some(jpeg) = jpeg { + return Some(std::borrow::Cow::Owned(jpeg)); + } + let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported()); + (undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff)) + .then_some(std::borrow::Cow::Borrowed(bytes)) +} + +/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see +/// [`stand_in_image`]). +pub(crate) fn decode_raw_preview( + bytes: &[u8], + opts: lightcraft_codecs::DecodeOptions, + covering: Option, +) -> Option { + let jpeg = stand_in_image(bytes, covering)?; + // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview + // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders + let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); + match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { + Some(space) => { + let space = match space { + lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, + lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb, + }; + lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok() + } + None => lightcraft_codecs::decode(&jpeg, opts).ok(), + } +} + +/// Oriented size of the embedded preview of a raw file. +pub(super) fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { + let jpeg = stand_in_image(bytes, None)?; + // headers only, as for other images (issue #367): decoding a full-size preview just for its + // size took about a second per file in an import; a decode remains the fallback + let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) { + Ok(header) => (header.width, header.height, header.orientation), + Err(_) => { + let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; + (d.source_width, d.source_height, d.orientation) + } + }; + if preview_orientation(bytes, orientation).swaps_axes() { + std::mem::swap(&mut w, &mut h); + } + Some((w, h)) +} + +/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented. +pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> { + // always the largest JPEG: a source's geometry must not depend on its size level (previews match exports) + let d = decode_raw_preview(bytes, fit_box(max_edge), None)?; + let img = d.to_working(); + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; + Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) +} + +/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the +/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest +/// embedded JPEG that covers `max_edge` is decoded, not the largest. +pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { + let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?; + if d.image.width.max(d.image.height) > max_edge { + d.image = fit(&d.image, max_edge, max_edge, Filter::Box); + d.alpha = None; + } + let o = preview_orientation(bytes, d.orientation); + Some(d.to_srgb8().oriented(o)) +} + +/// Decode options fitting into a `max_edge` square; `usize::MAX` (a full-size load) or any edge past +/// `u32::MAX` asks for the largest box rather than wrapping to a small one. +pub(super) fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions { + let e = u32::try_from(max_edge).unwrap_or(u32::MAX); + lightcraft_codecs::DecodeOptions::fit(e, e) +} + +/// Filesystem-backed embedded-preview hook (native). +/// +/// The quick path reads the file's header and its embedded preview and nothing else (see +/// [`preview_window`]); a container the header cannot point into is planned from its tags +/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a +/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts +/// the whole file, which is what the decode of one can hold. +pub fn fs_preview_loader() -> PreviewLoader { + Arc::new(|path: &str, max_edge: usize| { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) }; + if let Some(image) = preview_window(path, max_edge) + .ok() + .flatten() + .and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge)) + { + return Some(image); + } + embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge) + }) +} + +/// What [`preview_window`] read from a file, so a caller can see what a preview cost. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreviewRead { + /// Bytes read. + pub read: u64, + /// The file's size. + pub total: u64, + /// Whether the preview came from a bounded read (`false`: the whole file was read, which is what + /// every preview cost before the header path existed). + pub header_path: bool, + /// Whether the sparse planner named the ranges, rather than a format's own header fields: the + /// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on + /// the header path, and on the whole-file fallback). + pub planned: bool, + /// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise + /// (`decode_raw_preview` applies the same declaration): the quick decode applies it too. + pub adobe_rgb: bool, +} + +impl PreviewRead { + /// The share of the file that was read, `0.0`..=`1.0`. + pub fn fraction(self) -> f64 { + if self.total == 0 { + return 0.0; + } + (self.read as f64 / self.total as f64).clamp(0.0, 1.0) + } +} + +/// The file's header and its embedded preview, read without the rest of the file: `(header bytes, +/// preview bytes, what was read)`. `None` when the header points at no preview this build can find, +/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps +/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest +/// that covers it ([`lightcraft_raw::embedded_preview_covering`]). +/// +/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`), +/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten +/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview` +/// still needs the whole container, so the header comes back with the window; for a preview taken +/// out of a container that carries no preview colour space of its own (Nikon's maker note) the +/// whole-file fallback stays in charge. +pub fn preview_window(path: &str, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); + let mut prefix = read_prefix(&mut file, path, total)?; + + // What the header itself declares, before the container reader is asked: it clamps a slice to + // the buffer it was given, so a prefix that stops inside the declared preview yields a + // self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF: + // a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent + // first costs nothing — it lives in the first bytes of every format covered here. + if let Some(extent) = raf_preview_extent(&prefix) + && extent > prefix.len() as u64 + { + prefix = read_prefix_at(&mut file, path, extent.min(total))?; + } + let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { + // The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose + // preview tag sits past the prefix, the containers whose preview only a scan finds: ask the + // sparse planner which ranges `embedded_preview` reads before handing the caller the + // whole-file fallback. + return planned_preview(&mut file, path, total, max_edge); + }; + // A preview at the file's end may have been padded past it: clamp rather than fail. + let end = range.offset.saturating_add(range.window_len).min(total); + let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?; + // When the prefix already covers the range (the usual case: the prefix was grown to the declared + // extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview. + let prefix_len = prefix.len() as u64; + let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec); + let (window, bytes_read) = match from_prefix { + Some(window) => (window, prefix_len), + None => { + let mut window = vec![0u8; len]; + file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?; + (window, prefix_len + len as u64) + } + }; + + // A lying pointer must not cost the user their preview: hand the caller the whole-file fallback. + if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() { + return Ok(None); + } + let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false }; + // On a network share the bytes are the wait, so say what this preview actually cost. A window + // taken from the prefix cost nothing beyond the prefix itself. + if lightcraft_pipeline::profiling() { + eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len()); + } + Ok(Some((prefix, window, read))) +} + +/// The most times the sparse planner may name ranges before the preview gives up: the plan is +/// monotone (it never asks for a range twice), so a real file needs a handful. +const MAX_PREVIEW_PLAN_ROUNDS: usize = 8; + +/// Tenths of a file the planner may have read before the preview gives up and the whole-file path +/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the +/// file has stopped being one. +const MAX_PREVIEW_PLAN_SHARE: u64 = 6; + +/// The largest file the planner path builds its buffer for. The raws it serves are far below this; +/// a file past it is read whole as before rather than allocating its length up front. +const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30; + +/// The header the quick path is handed on the planner route. The header route grows its prefix to +/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out +/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough +/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes. +pub(super) const PLAN_PREFIX: u64 = 64 * 1024; + +/// The preview of a container the header-only path cannot answer for, from the byte ranges +/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads. +/// +/// The planner is asked about a buffer of the file's whole length in which only the ranges named so +/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The +/// buffer is the file's length, which the loader's memory gate already counts, and its untouched +/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in +/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so +/// this path's cost is the bytes read, not a promise about RSS. +/// +/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the +/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it +/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when +/// the quick path could not reproduce what the whole-file path shows (the two container facts +/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded +/// gives what the whole file gives), so a window that still fails to decode ends in the same +/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture. +fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { + use std::io::{Read, Seek, SeekFrom}; + + if total > MAX_PREVIEW_PLAN_BYTES { + return Ok(None); + } + let Ok(len) = usize::try_from(total) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?; + let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) }; + head.copy_from_slice(&header); + let mut loaded = lightcraft_raw::sparse::Ranges::new(); + loaded.insert(0, header.len() as u64); + for _ in 0..MAX_PREVIEW_PLAN_ROUNDS { + match lightcraft_raw::sparse::preview_plan(&buf, &loaded) { + lightcraft_raw::sparse::HeaderPlan::Need(ranges) => { + let wanted = loaded.total().saturating_add(ranges.total()); + if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) { + return Ok(None); + } + for &(start, end) in ranges.spans() { + let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(a..b) else { return Ok(None) }; + file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?; + loaded.insert(start, end); + } + } + lightcraft_raw::sparse::HeaderPlan::Ready => { + // the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick + let edge = u32::try_from(max_edge).unwrap_or(u32::MAX); + let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) }; + // The quick path hands the JPEG to the decoder alone, so what the *container* says + // about the preview has to be something the bytes it is handed can show. Two container + // facts change the pixels and are not in the JPEG: the preview's colour space (which + // the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries + // no orientation of its own, the file's EXIF orientation. The colour space is read + // here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews, + // which used to send each of them to the whole-file path, issue #1440); a container + // that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB). + // Where the orientation is present and the header cannot answer for it, the + // whole-file path keeps the preview. + let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb); + // `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side + // first: a JPEG that carries its own orientation needs nothing from the container. + if lightcraft_meta::extract(&window).orientation.is_none() + && lightcraft_meta::extract(&header).orientation.is_none() + && lightcraft_meta::extract(&buf).orientation.is_some() + { + return Ok(None); + } + let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb }; + // A plan-driven preview is the one that used to cost the whole file, so it is worth + // saying what it cost now too. + if lightcraft_pipeline::profiling() { + eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len()); + } + // The planner's buffer is the file's length, so give its pages back the way a decode + // does (see `fs_pair_loader`). + drop(buf); + crate::memory::release(); + return Ok(Some((header, window, read))); + } + lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None), + } + } + Ok(None) +} diff --git a/crates/engine/src/files/probe.rs b/crates/engine/src/files/probe.rs new file mode 100644 index 000000000..aff0b884c --- /dev/null +++ b/crates/engine/src/files/probe.rs @@ -0,0 +1,223 @@ +use super::load::WbScaleOf; +use super::preview::embedded_preview_size; +use super::*; + +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(); + let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string(); + let meta = Meta { + camera, + // (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers) + lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(), + focal_mm: m.focal_length.map(|f| f as f32), + aperture: m.f_number.map(|f| f as f32), + shutter, + iso: m.iso, + location: m.sublocation.clone().unwrap_or_default(), + city: m.city.clone().unwrap_or_default(), + state: m.state.clone().unwrap_or_default(), + country: m.country.clone().unwrap_or_default(), + alt_text: m.alt_text.clone().unwrap_or_default(), + extended_description: m.extended_description.clone().unwrap_or_default(), + gps: m.gps.as_ref().map(|g| (g.latitude, g.longitude)), + title: m.title.clone().unwrap_or_default(), + caption: m.caption.clone().unwrap_or_default(), + copyright: m.copyright.clone().unwrap_or_default(), + copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked), + usage_terms: m.usage_terms.clone().unwrap_or_default(), + copyright_url: m.copyright_url.clone().unwrap_or_default(), + creator: m.artist.clone().unwrap_or_default(), + // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels + keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), + regions: m.regions.clone(), + focus: m.focus.clone(), + }; + (meta, m.capture_time.as_ref().map(|d| d.to_iso())) +} + +/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the +/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the +/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. +pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES}; + use lightcraft_geom::Point; + use lightcraft_raw::Opcode; + let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); + if aw < 2.0 || ah < 2.0 { + return None; + } + let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + let (cx0, cy0, cw, ch) = + if c.width == 0 || c.height == 0 { (0.0, 0.0, aw, ah) } else { (c.x as f64, c.y as f64, c.width as f64, c.height as f64) }; + let long = cw.max(ch); + // opcode centres are relative to the (uncropped) active area, in pixel-index units + let centre = |rel: [f64; 2]| -> (Point, f64) { + let (px, py) = (rel[0] * (aw - 1.0), rel[1] * (ah - 1.0)); + let m = [(0.0, 0.0), (aw - 1.0, 0.0), (0.0, ah - 1.0), (aw - 1.0, ah - 1.0)] + .iter() + .map(|&(x, y)| (x - px).hypot(y - py)) + .fold(0.0, f64::max) + .max(1e-9); + (Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long) + }; + let mut lens = EmbeddedLens::default(); + // opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional + // WarpRectilinear2) + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect(); + // A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output); + // without one the first WarpRectilinear is the lens warp, as it always was. + let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })); + let mut stages = Vec::new(); + for op in ops { + match op { + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => { + let (center, radius) = centre(*center); + let p = |i: usize| planes[i.min(planes.len() - 1)]; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal }); + } + Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => { + // WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit + let (center, radius) = centre(*center); + let p = |i: usize| { + let k = planes[i.min(planes.len() - 1)]; + let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + s[0] = k[0]; + s[2] = k[1]; + s[4] = k[2]; + s[6] = k[3]; + s[15] = k[4]; + s[16] = k[5]; + s[18] = 1e6; + s + }; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false }); + } + Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => { + let (center, radius) = centre(*center); + let p = |i: usize| planes[i.min(planes.len() - 1)]; + lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius }); + } + Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { + let (center, radius) = centre(*center); + lens.vignette = Some(EmbeddedVignette { k: *k, center, radius }); + } + _ => {} + } + } + // (more stages than a lens keeps: the first ones; real files carry two) + for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) { + *slot = Some(st); + } + if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() { + return None; + } + Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch)) +} + +fn ext_upper(name: &str) -> String { + std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() +} + +/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed: +/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too +/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside +/// compressed data that is all there: such a file imports and shows as unreadable when rendered). +pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { + let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); + let m = lightcraft_meta::extract(bytes); + let (meta, captured) = meta_of(&m); + if lightcraft_raw::probe(bytes).is_some() { + let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) { + Ok(r) => r, + Err(Ok(why)) => { + // a raw variant we can't decode yet: describe it from its embedded preview + let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?; + return Ok(ProbeInfo { + width: w, + height: h, + format: ext_upper(name), + kind: MediaKind::Raw, + file_size: bytes.len() as u64, + captured, + meta, + as_shot_wb: None, + content_hash, + xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: Some(why), + ..Default::default() + }); + } + Err(Err(e)) => return Err(e), + }; + let (w, h) = raw.developed_size(); + let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); + if raw.orientation.swaps_axes() { + std::mem::swap(&mut w, &mut h); + } + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw))); + let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone()); + return Ok(ProbeInfo { + embedded_lens, + width: w, + height: h, + format: ext_upper(name), + kind: MediaKind::Raw, + file_size: bytes.len() as u64, + captured, + file_modified: None, + meta, + as_shot_wb, + content_hash, + xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: None, + }); + } + let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; + if let Some(why) = fmt.not_decodable() { + // e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft" + return Err(format!("{fmt:?} files can't be opened: {why}")); + } + // headers only (issue #367: decoding the pixels was nearly all of an import's CPU time) + let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?; + let o = Orientation::from_exif(header.orientation); + let (mut w, mut h) = (header.width, header.height); + if o.swaps_axes() { + std::mem::swap(&mut w, &mut h); + } + let format = match ext_upper(name).as_str() { + "JPG" | "JPEG" => "JPEG".to_string(), + "" => format!("{fmt:?}").to_uppercase(), + e => e.to_string(), + }; + Ok(ProbeInfo { + width: w, + height: h, + format, + kind: MediaKind::Image, + file_size: bytes.len() as u64, + captured, + file_modified: None, + meta, + as_shot_wb: None, + content_hash, + embedded_lens: None, + xmp: None, + preview_only: None, + }) +} + +/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error +/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX +/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed. +/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF +/// structure): its preview may still be intact. Only a file that is not a raw reports an error. +pub(super) fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result { + use lightcraft_raw::RawError; + match e { + RawError::Unsupported(why) => Ok(why), + e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")), + RawError::NotRaw => Err(RawError::NotRaw.to_string()), + e => Ok(e.to_string()), + } +} diff --git a/crates/engine/src/files/tests.rs b/crates/engine/src/files/tests.rs new file mode 100644 index 000000000..e8bcebe4c --- /dev/null +++ b/crates/engine/src/files/tests.rs @@ -0,0 +1,1776 @@ +/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the +/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides +/// them; an exported `travel|Italy|Rome` comes back as itself. +#[test] +fn a_lens_without_a_name_is_shown_by_its_range() { + // a Nikon DSLR raw: no LensModel, the range from the maker note + let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; + assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); + let named = lightcraft_meta::Metadata { + lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), + lens_spec: Some([24.0, 70.0, 4.0, 4.0]), + ..Default::default() + }; + assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); + assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); +} + +#[test] +fn imported_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], + hierarchical_keywords: vec!["travel|Italy|Rome".into()], + ..Default::default() + }; + assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); +} + +#[test] +fn psd_probe_reads_embedded_capture_time_gps_and_title() { + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let metadata = lightcraft_meta::Metadata { + capture_time: Some(capture), + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSD metadata".into()), + ..Default::default() + }; + let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); + let probe = probe_bytes("metadata.psd", &bytes).unwrap(); + assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); + assert_eq!(probe.meta.gps, Some((45.0, 7.0))); + assert_eq!(probe.meta.title, "PSD metadata"); +} + +use super::*; +use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + +/// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a +/// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot +/// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the +/// format and camera string agrees. +#[test] +fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let mul = Some([2.2f32, 1.0, 1.6]); + let d65 = lightcraft_color::D65; + // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe + let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); + let (t, tint) = a7m3.as_shot(d65); + assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); + // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + assert_eq!(bare.as_shot(d65), { + let (t, tint) = xy_to_temp_tint(d65); + (t.round(), tint.round()) + }); + // a camera without measured matrices: relative, whatever the multipliers + let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); + assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); + assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as + // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with = ColorData { kelvin_presets: presets, ..Default::default() }; + let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); + assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); + let (t, tint) = a99m2.as_shot(d65); + assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a + // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) + let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (8529.0, g(1807.0, 1080.0)), + ]); + let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; + let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); + assert!(!g1x3.relative && g1x3.scale_white.is_some()); + let (t, tint) = g1x3.as_shot(d65); + assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone + let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); + assert!(other.relative && other.scale_white.is_none()); + let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); + assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); + // a file with matrices of its own (DNG): absolute from them, no matter whose camera + let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; + let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); + let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); + assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); + assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); +} + +/// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source +/// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative +/// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets +/// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. +#[test] +fn a_merged_dng_shows_the_white_balance_its_source_shows() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; + let cases = [ + (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), + (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), + (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), + (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), + ]; + for (format, color, mul, meta) in cases { + let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); + let source = WbScaleOf::new(format, &color, Some(mul), &meta); + let t = lightcraft_raw::color::camera_transform_of(&color, own); + let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); + let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); + let shown = source.as_shot(own); + assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); + } +} + +/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. +fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec { + let head = 160; + let mut f = vec![0u8; head]; + f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW "); + f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes()); + f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes()); + f.extend_from_slice(jpeg); + f.extend(std::iter::repeat_n(0x33u8, tail)); + f +} + +/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a +/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten +/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the +/// quick path produces must equal the whole-file path's, exactly. +#[test] +fn preview_loading_reads_only_the_header_and_the_jpeg() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = raf_with_embedded_jpeg(&jpeg, 200_000); + let dir = crate::tests_xmp::temp_dir("preview-window"); + let path = dir.join("dscf0001.raf"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview"); + assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); + assert!(read.header_path, "the header path is what answered"); + assert!(!read.planned, "a RAF pointer in the header needs no planner"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); + assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); + + // The quick path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // A file with no locatable preview declines (the caller then reads it whole), a missing file errors. + let plain = dir.join("plain.jpg"); + std::fs::write(&plain, &jpeg).unwrap(); + assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none()); + assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the +/// file's own orientation, and the EXIF colour space the container declares for its preview. +#[derive(Clone, Copy, Default)] +struct Fixture { + dng: bool, + sony: bool, + orientation: Option, + /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. + color_space: Option, +} + +/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the +/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of +/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than +/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. +fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { + tiff_with_preview_strips(strip, &[], tail, what) +} + +/// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its +/// 1616 px preview beside the full-size one. +fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut preview = IfdBuilder::new(); + if what.dng { + preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + if what.sony { + preview.set(t::MAKE, Value::Ascii("SONY".into())); + } + if let Some(o) = what.orientation { + preview.set(t::ORIENTATION, Value::Short(vec![o])); + } + if let Some(space) = what.color_space { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![space])); + if space == 0xFFFF { + let mut interop = IfdBuilder::new(); + interop.set(0x0001, Value::Undefined(b"R03".to_vec())); + exif.set_child(t::INTEROP_IFD, interop); + } + preview.set_child(t::EXIF_IFD, exif); + } + preview.set(t::COMPRESSION, Value::Short(vec![7])); + preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); + for jpeg in smaller { + let mut sub = IfdBuilder::new(); + sub.set(t::COMPRESSION, Value::Short(vec![7])); + sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + sub.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + sub.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] }); + preview.add_sub_ifd(sub); + } + let mut raw = IfdBuilder::new(); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); + raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); + TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") +} + +/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview +/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges +/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels +/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline +/// rather than guess, so the caller keeps reading it whole. +#[test] +fn preview_loading_plans_the_ranges_it_needs() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan"); + let path = dir.join("preview.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview"); + assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); + assert!(read.planned, "the planner is what answered"); + assert!(read.header_path, "and it is still a bounded read"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); + assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); + + // The plan path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // The same structure without the tag that makes it a raw: the planner declines, and the + // caller's whole-file read is what answers (no guessing at what a file might hold). + let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); + std::fs::write(&path, ¬_a_raw).unwrap(); + assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that +/// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an +/// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one. +#[test] +fn preview_loading_plans_the_smallest_covering_jpeg() { + let jpeg = |w: u32, h: u32| { + let rgb: Vec = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect(); + encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap() + }; + let (big, small) = (jpeg(96, 64), jpeg(48, 32)); + assert!(big.len() > small.len(), "the full-size JPEG is the largest"); + let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-covering"); + let path = dir.join("two-previews.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] { + let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview"); + assert!(read.planned, "the planner is what answered"); + assert!(&window == want, "a {edge} px box takes the {which} JPEG"); + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height, quick.data), (whole.width, whole.height, whole.data), "{edge} px: the plan and the whole file agree"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file whose preview the quick path could not show the way the whole-file path does is left to +/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is +/// bigger than that header) and the preview JPEG carries none of its own, so a window read would +/// come out unrotated where the whole-file path rotates it — the plan must decline instead of +/// handing on a wrong picture. +#[test] +fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts + // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image + let mut strip = jpeg.clone(); + strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); + let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); + let path = dir.join("rotated.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); + assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); + assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); + assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A container can declare what its preview JPEG is in, and the whole-file path applies that +/// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies +/// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and +/// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one +/// that declares sRGB (what most bodies write) keeps the fast path as before. +#[test] +fn preview_loading_applies_a_container_colour_space() { + use lightcraft_raw::PreviewColorSpace; + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let dir = crate::tests_xmp::temp_dir("preview-plan-space"); + let path = dir.join("space.arw"); + let path = path.to_string_lossy().into_owned(); + + let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); + std::fs::write(&path, &adobe).unwrap(); + let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too"); + assert!(read.planned && read.adobe_rgb, "{read:?}"); + assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}"); + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes"); + let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too"); + assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it"); + let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes"); + assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)"); + + let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); + std::fs::write(&path, &srgb).unwrap(); + let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); + assert!(read.planned && !read.adobe_rgb, "the plan still answers"); + assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); +} + +/// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as +/// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. +#[test] +fn undated_files_take_the_file_date() { + let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rgb = vec![128u8; 16 * 16 * 3]; + let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let path = dir.join("export.jpg"); + std::fs::write(&path, &jpeg).unwrap(); + let path = path.to_string_lossy().to_string(); + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let want = crate::import::civil(mtime as i64); + let (_, probe) = fs_hooks(); + let info = probe(&path).unwrap(); + assert_eq!(info.captured, None, "the file itself has no date"); + assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); + let mut s = crate::Session::new().with_fs(); + let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); + let id = lightcraft_catalog::PhotoId(r.imported[0]); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: +/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated +/// file or one that isn't an image. +#[test] +fn image_probe_reads_headers_like_the_decode_did() { + let (w, h) = (300u32, 200u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + for o in [None, Some(1), Some(6), Some(3), Some(8)] { + let exif = o.map(lightcraft_codecs::exif::minimal_exif); + let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); + let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); + for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { + let p = probe_bytes(name, bytes).unwrap(); + // what the probe computed from a decode before + let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); + let swap = Orientation::from_exif(d.orientation).swaps_axes(); + let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; + assert_eq!((p.width, p.height), want, "{name} {o:?}"); + assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); + assert_eq!(p.kind, MediaKind::Image); + assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); + assert_eq!(p.file_size, bytes.len() as u64); + assert!(p.content_hash.is_some()); + assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); + } + } + assert!(probe_bytes("x.jpg", b"not an image").is_err()); +} + +/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets +/// start from), not on the black its transparent pixels store. +#[test] +fn transparent_png_develops_on_white() { + let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; + let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); + let (img, _) = load_bytes(&png, 64).unwrap(); + assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); + assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); +} + +/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample +/// of a block on a grey surface must not become green speckles (the block's green used to take the +/// clip level, which highlight reconstruction then kept). +#[test] +fn coarse_binning_keeps_specular_points_neutral() { + let (w, h) = (256usize, 192usize); + // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right + let neutral = [0.5f32, 1.0, 0.7]; + let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); + let mut data: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; + (256.0 + v * (16000.0 - 256.0)).round() as u16 + }) + .collect(); + // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block + for by in 0..h / 8 { + for bx in (by % 2..w / 8).step_by(2) { + data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; + } + } + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(bin_factor(&raw, 32), Some(8)); + let (img, _) = load_bytes(&bytes, 32).unwrap(); + assert_eq!((img.width, img.height), (32, 24)); + let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); + assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); +} + +#[test] +fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { + use lightcraft_denoise::product; + use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng}; + let dir = crate::tests_xmp::temp_dir("denoise-color-finish"); + let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&fixture).unwrap(); + raw.orientation = Orientation::Rotate90; + raw.color.baseline_exposure = 0.5; + raw.color.profile.look_table = + Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false }); + let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap(); + let decoded = lightcraft_raw::decode(&bytes).unwrap(); + assert!(decoded.color.profile.look_table.is_some()); + // A cache containing the ordinary demosaic must undergo the identical downstream finish. + let camera_rgb = decoded.develop(Method::Ahd).unwrap(); + let key = "synthetic-color-finish"; + let path = dir.join("same.lcdn"); + product::write(&path, &camera_rgb, key).unwrap(); + let spec = DenoiseSpec { product: path, key: key.into(), make: None }; + let (ordinary, _) = load_bytes(&bytes, 2048).unwrap(); + let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap(); + assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path"); + let cached = cached.unwrap(); + assert_eq!((plain.width, plain.height), (24, 32)); + assert_eq!((cached.width, cached.height), (plain.width, plain.height)); + let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG +/// mostly cancel, edges remain. +fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { + let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); + let mut out = vec![0.0; w * h]; + for y in 0..h { + for x in 0..w { + let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); + let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); + out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; + } + } + out +} + +/// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its +/// normalised cross-correlation, or `None` when the patch has too little detail to tell. +fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { + let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); + let na = pa.iter().map(|v| v * v).sum::().sqrt(); + if na < 1e-3 * p as f32 { + return None; + } + let mut best = (f32::MIN, (0, 0)); + for dy in -r..=r { + for dx in -r..=r { + let (bx, by) = (x as i64 + dx, y as i64 + dy); + let (mut dot, mut nb) = (0.0f32, 0.0f32); + for i in 0..p { + for j in 0..p { + let v = b[(by as usize + i) * w + bx as usize + j]; + dot += pa[i * p + j] * v; + nb += v * v; + } + } + let c = dot / (na * nb.sqrt()).max(1e-12); + if c > best.0 { + best = (c, (dx, dy)); + } + } + } + Some((best.1, best.0)) +} + +#[test] +fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { + use lightcraft_geom::{Orientation, Point}; + use lightcraft_pipeline::optics::Warp; + let root = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { + let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; + let off = lightcraft_raw::probe_info(&bytes).unwrap(); + assert!(embedded_lens(&off).is_none()); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let at = tiff.find(0x7036).unwrap().offset as usize; + let mut enabled = Vec::new(); + tiff.order.put_u16(&mut enabled, 17); + bytes[at..at + 2].copy_from_slice(&enabled); + let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); + raw.orientation = Orientation::Normal; + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.optics.lens_profile = true; + let base_lens = embedded_lens(&raw).unwrap(); + let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); + for orientation in 1..=8 { + let o = Orientation::from_exif(orientation); + raw.orientation = o; + let lens = embedded_lens(&raw).unwrap(); + for edge in [1200.0, 2560.0, 9504.0] { + let scale = edge / 9504.0; + let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; + let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); + for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { + let point = Point::new(x * 9504.0, y * 6336.0); + let source = base.corrected_to_source(point, 1); + let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); + let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); + let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); + assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); + } + } + } + } +} + +/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the +/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile +/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide +/// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. +#[test] +fn corpus_rw2_distortion_matches_the_camera_jpeg() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); + return; + }; + assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); + assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); + + // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) + let files = [ + ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners + ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % + ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % + ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % + ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction + ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % + ]; + const EDGE: usize = 640; + const P: usize = 64; + const R: i64 = 12; + for (name, off_at_least) in files { + let bytes = std::fs::read(dir.join(name)).unwrap(); + let probed = probe_bytes(name, &bytes).unwrap(); + assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); + let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); + assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); + // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` + let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); + assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); + let mut s = lightcraft_develop::DevelopSettings::default(); + let render = |s: &lightcraft_develop::DevelopSettings| { + lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image + }; + let before = render(&s); + s.optics.lens_profile = true; + let after = render(&s); + let (w, h) = (after.width, after.height); + assert_eq!((before.width, before.height), (w, h)); + // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre + let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); + let (cw, ch) = (cam.width as f64, cam.height as f64); + let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; + let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); + let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); + let lin = |v: u8| (v as f32 / 255.0).powf(2.2); + let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { + let p = im.data[y * im.width + x]; + 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) + }; + let reference = detail(w, h, |x, y| { + let p = cam.data[y * w + x]; + 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] + }); + let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); + let m = R as usize + 4; + let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; + let mut measured = 0; + for c in corners { + let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; + measured += 1; + assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); + if off_at_least > 0 + && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) + { + let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; + assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); + } + } + assert!(measured >= 2, "{name}: only {measured} corners with detail"); + } +} + +/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. +pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec { + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); + b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes()); + b.extend_from_slice(b"PRVW\0\0\0\0\0\x01"); + b.extend_from_slice(&(w as u16).to_be_bytes()); + b.extend_from_slice(&(h as u16).to_be_bytes()); + b.extend_from_slice(b"\0\x01"); + b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes()); + b.extend_from_slice(&jpeg); + b +} + +/// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: +/// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). +fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { + let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); + craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); + craw.extend(bx(b"CMP1", &[0xff; 4])); + let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; + stsd.extend(bx(b"CRAW", &craw)); + let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); + let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); + let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); + let preview = cr3_with_preview(w, h); + [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() +} + +/// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: +/// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). +#[test] +fn corrupt_cr3_falls_back_to_embedded_preview() { + let b = cr3_with_corrupt_raw(48, 32); + let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); + assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // other formats still report a corrupt file as an error + let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + dng.truncate(dng.len() / 2); + assert!(load_bytes(&dng, 24).is_err()); +} + +/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is +/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. +#[test] +fn dng_profile_look_is_applied() { + use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let sat = |img: &Rgb32f| { + img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() + / img.data.len() as f32 + }; + let (before, info) = load_bytes(&plain, 64).unwrap(); + assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); + assert!(info.camera_tone.is_none()); + // a map that removes all saturation, and a tone curve + let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; + raw.color.profile = ProfileLook { + hue_sat_map: [Some(grey), None], + look_table: None, + tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]), + gain_table_map: None, + }; + let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (after, info) = load_bytes(&with, 64).unwrap(); + assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); + // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) + let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; + assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); + let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); + assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); + // a look table alone changes the render too (applied after exposure) + raw.color.profile = ProfileLook { + look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), + ..Default::default() + }; + let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); +} + +/// Corrections used while fitting a DNG's preview must not be baked into its editable source. +/// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. +#[test] +fn dng_preview_corrections_leave_the_render_source_uncorrected() { + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (before, expected) = load_bytes(&before, 64).unwrap(); + let (after, info) = load_bytes(&corrected, 64).unwrap(); + assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); + assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); + assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); +} + +/// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. +/// The source pixels stay uncorrected, including when the file is rotated. +#[test] +fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { + use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; + use lightcraft_raw::{Opcode, Orientation}; + let (w, h) = (384usize, 256usize); + let data = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; + let colour = match (i / w % 2, i % w % 2) { + (0, 0) => 1.2, + (1, 1) => 0.8, + _ => 1.0, + }; + 256 + (v * colour * 15744.0) as u16 + }) + .collect(); + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.color = lightcraft_raw::ColorData { + illuminant: [21, 0], + color_matrix: [Some(REC2020.from_xyz()), None], + as_shot_neutral: Some([1.0; 3]), + ..Default::default() + }; + let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![ + Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, + ]; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let to_srgb = REC2020.to_space(&SRGB); + let pixels: Vec = corrected + .data + .iter() + .flat_map(|p| { + let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); + let y = luminance_2020(p).max(1e-6); + let gain = (1.0 - (-3.0 * y).exp()) / y; + to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + for orientation in [Orientation::Normal, Orientation::Rotate90] { + raw.orientation = orientation; + let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + // Append a preview IFD to the little-endian DNG written above. + let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; + let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; + let next = root + 2 + 12 * count; + let start = file.len() as u32; + file[next..next + 4].copy_from_slice(&start.to_le_bytes()); + let jpeg_at = start + 2 + 4 * 12 + 4; + file.extend(4u16.to_le_bytes()); + for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { + file.extend(tag.to_le_bytes()); + file.extend(4u16.to_le_bytes()); + file.extend(1u32.to_le_bytes()); + file.extend(value.to_le_bytes()); + } + file.extend(0u32.to_le_bytes()); + file.extend(&jpeg); + let (source, info) = load_bytes(&file, 96).unwrap(); + let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); + let target = 1.0 - (-3.0f32 * 0.18).exp(); + assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); + let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); + assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); + } +} + +/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's +/// own white-balanced neutral comes out yellow (blue 12 % low). +fn yellowing_look() -> lightcraft_color::Mat3 { + lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) +} + +/// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the +/// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. +#[test] +fn camera_look_in_lanes_matches_pixel_by_pixel() { + let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); + let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; + let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); + let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; + let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; + let img = Rgb32f::from_fn(37, 5, |x, y| { + let k = y * 37 + x; + if k % 5 == 0 { + [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] + } else { + [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] + } + }); + let mut lanes = img.clone(); + colour.apply(&mut lanes); + let m = colour.matrix.to_f32(); + for (p, got) in img.data.iter().zip(&lanes.data) { + assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); + } +} + +#[test] +fn clipped_white_renders_neutral_through_the_colour_model() { + use lightcraft_color::{LUMA_2020, Mat3}; + let look = yellowing_look(); + let white = clipped_white(&look); + let out = look.apply(white.map(f64::from)); + assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}"); + let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::(); + assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral"); + assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]); + // a model that can't be inverted, or only just, keeps the camera's neutral + assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // so does one whose white would overflow the rebuilt highlights (2.5e38 in red here) + assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // or move the camera's neutral implausibly far + assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite + for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] { + let w = clipped_white(&model); + let m = model.to_f32(); + let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]); + assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}"); + let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None }; + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0)); + } +} + +/// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that +/// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral; +/// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it. +#[test] +fn clipped_highlights_stay_neutral_when_darkened() { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.4f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None }; + // a grey wall (camera neutral) with a fully clipped light and a light clipped in green only + let mut img = Rgb32f::from_fn(96, 64, |x, y| { + let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + if d(28.0) < 14.0 { + [1.0; 3] + } else if d(70.0) < 12.0 { + [0.6 / wb[0], 1.0, 0.7 / wb[2]] + } else { + [0.3 / wb[0], 0.3, 0.3 / wb[2]] + } + }); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut issue = lightcraft_develop::DevelopSettings::default(); + let l = &mut issue.light; + (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); + let mut h100 = lightcraft_develop::DevelopSettings::default(); + h100.light.highlights = -100.0; + for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] { + let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image; + let p = out.get(28, 32); + assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})"); + let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); + assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral"); + } +} + +/// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`) +/// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise +/// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last +/// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row. +fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let w = 1600usize; + let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; + let sensor = Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let y = (rows.start + rows.end) / 2; + let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0)) +} + +/// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards +/// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels +/// there. +#[test] +fn rendered_colour_is_continuous_where_the_first_channel_clips() { + let ([a, b, c], worst) = darkened_ramp(0..16, 16); + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels"); +} + +/// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there +/// is no reliable colour around it. It still carries on across the first clip, however thin the line and however +/// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163]; +/// on 512 rows, from [225, 126, 153] to [218, 145, 151]). +#[test] +fn rendered_thin_line_is_continuous_where_the_first_channel_clips() { + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + for height in [32usize, 128, 512, 1024] { + for width in [1usize, 2, 4, 16] { + let top = (height - width) / 2; + let ([a, b, c], worst) = darkened_ramp(top..top + width, height); + let case = format!("{width} rows of {height}"); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } + } +} + +/// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1, +/// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale +/// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows +/// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the +/// rendered colour on either side of where red clips on the band's middle row, and the largest step between +/// neighbouring pixels along that row. +fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let (w, h) = (320usize, top + strip + band); + let sensor = Rgb32f::from_fn(w, h, |x, y| { + if y < top { + [0.8, 0.35, 0.3] + } else if y < top + strip { + [0.4, 0.95, 0.8] + } else { + [0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8] + } + }); + let y = top + strip + band / 2; + let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0)) +} + +/// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The +/// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly +/// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then +/// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped +/// (without the coloured surface: [198, 195, 178] then [205, 181, 168]). +#[test] +fn rendered_colour_is_continuous_where_a_second_channel_clips() { + for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] { + let ([a, b], worst) = second_clip(top, strip, band); + let case = format!("{top} rows of colour, {strip} of rim, {band} of band"); + let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0); + assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } +} + +/// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in +/// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo +/// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them). +#[test] +fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); + return; + }; + let (img, _) = load_bytes(&bytes, 1600).unwrap(); + let (w, h) = (img.width, img.height); + let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p); + let chroma = |p: [f32; 3]| { + let s = (p[0] + p[1] + p[2]).max(1e-6); + [p[0] / s, p[2] / s] + }; + // the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame + let px = |f: f32, n: usize| (f * n as f32) as usize; + let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]); + let mut rows = 0; + for y in px(0.24, h)..px(0.34, h) { + let row = &img.data[y * w..(y + 1) * w]; + let sky = lum(row[px(0.2, w)]); + let dark: Vec = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect(); + let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue }; + let k = w as f32 / 6000.0; + let mean = |a: usize, b: usize| { + let n = (b - a) as f64; + (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) + }; + let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); + for (side, (n, f)) in [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] + .into_iter() + .enumerate() + { + for c in 0..2 { + near[side][c] += n[c]; + far[side][c] += f[c]; + } + } + rows += 1; + } + assert!(rows > 50, "the tree was not found ({rows} rows)"); + for side in 0..2 { + let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max); + assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]); + } +} + +/// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the +/// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The +/// copy's samples are much brighter than the image's here, so a picture says which one it came from. +#[test] +fn exports_never_develop_a_dngs_reduced_copy() { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + use serde_json::json; + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); + ifd + }; + // (the copy must cover a thumbnail source's 512 px) + let (w, h) = (1200u32, 600u32); + let mut main = linear(0, w, h, 1500); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); + ifd0.add_sub_ifd(main); + ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + + // the loaders: only the thumbnail's develops the copy, and only when it covers the size + let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); + assert!(used, "a thumbnail develops the copy"); + assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); + let (full, _) = load_vec(bytes.clone(), 512).unwrap(); + assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); + + let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("merge-pano.dng"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); + // an export at a thumbnail's size, before any thumbnail + assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); + let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + // the thumbnail develops the copy and its source stays in memory… + s.media.clear_sources(); + let thumb = s.thumb_job(id, 64).unwrap().run(); + assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); + let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); + s.accept(&thumb); + assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); + // …and an export after it still reads the full image + let job = s.export_job(id, 64, 64, space, depth).unwrap(); + assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); + let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + assert!((again - export).abs() < 1.0, "{again} vs {export}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right +/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky +/// heuristic would never look) is used; a matte with an unknown name is ignored. +#[test] +fn dng_sky_matte_drives_the_sky_mask() { + use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_pipeline::{RenderRequest, render}; + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + let (w, h) = (16u32, 8u32); + let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec| { + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] }); + }; + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize)); + let matte = |name: &str, right: bool| { + let mut m = IfdBuilder::new(); + m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK])); + m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK])); + m.set(t::SEMANTIC_NAME, Value::Ascii(name.into())); + image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect()); + m + }; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("Apple".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![6])); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]); + ifd0.add_sub_ifd(raw); + ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true)); + ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + + let (img, info) = load_bytes(&bytes, 64).unwrap(); + assert_eq!((img.width, img.height), (8, 16)); + assert!(info.mattes.is_some()); + let sky = Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], + adjust: LocalAdjustments { exposure: -3.0, ..Default::default() }, + ..Default::default() + }; + let s = DevelopSettings { masks: vec![sky], ..Default::default() }; + let req = RenderRequest::fit(8, 16); + let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32; + let plain = DevelopSettings::default(); + assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top"); + assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened"); + // without the matte the heuristic looks at the top of the frame instead + let heuristic = SourceInfo { mattes: None, ..info.clone() }; + assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2)); + assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13)); +} + +/// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the +/// pipeline the file's colour tags (without the profile look, applied at load) and the white +/// the pixels were developed for, and a custom white balance renders a neutral differently +/// from the Bradford adaptation it replaces. +#[test] +fn dng_white_balance_uses_the_camera_colour_model() { + use lightcraft_pipeline::{RenderRequest, render}; + let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let raw = lightcraft_raw::decode(&dng).unwrap(); + let (img, info) = load_bytes(&dng, 64).unwrap(); + let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix"); + assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw)); + assert!(cc.tags.profile.is_empty()); + assert_eq!(cc.tags.color_matrix, raw.color.color_matrix); + let mut s = lightcraft_develop::DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0); + let req = RenderRequest::fit(32, 32); + let camera = render(&img, &info, &s, &req).image; + let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image; + assert_ne!(camera.data, adapted.data); +} + +/// Apple ProRAW: like Lightroom Classic, the default render ignores the file's +/// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel +/// changes nothing. +#[test] +fn dng_gain_table_map_is_not_rendered() { + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 1, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [0.2, 0.2, 0.2, 0.2, 0.2], + gamma: 1.0, + gains: vec![2.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept"); + let (after, _) = load_bytes(&with_map, 64).unwrap(); + assert_eq!(before.data, after.data); +} + +/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's +/// active area, whatever the photo's orientation), and only when it's on. +#[test] +fn dng_gain_table_map_renders_when_asked() { + use lightcraft_pipeline::{RenderRequest, render}; + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + // ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 2, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], + gamma: 1.0, + gains: vec![1.0, 4.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (img, info) = load_bytes(&with_map, 64).unwrap(); + let (_, plain_info) = load_bytes(&plain, 64).unwrap(); + assert!(info.local_tone.is_some() && plain_info.local_tone.is_none()); + let req = RenderRequest::fit(64, 64); + let mut s = lightcraft_develop::DevelopSettings::default(); + // (darker, so that ×4 never reaches white) + s.light.exposure = -3.0; + let off = render(&img, &info, &s, &req).image; + assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing"); + s.profile.camera_local_tone = true; + assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)"); + let on = render(&img, &info, &s, &req).image; + // brightness gained, per column: none at the left edge, most at the right + let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::(); + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + let last = on.width - 1; + assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0)); + assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last)); + // the photo turned upside down: the map follows the raw, so the brightened side swaps + s.orientation = lightcraft_geom::Orientation::Rotate180; + s.profile.camera_local_tone = false; + let off = render(&img, &info, &s, &req).image; + s.profile.camera_local_tone = true; + let on = render(&img, &info, &s, &req).image; + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last)); + // a rendered (non-raw) source never gets it + let jpeg = SourceInfo { raw: false, ..info.clone() }; + assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s)); +} + +#[test] +fn unsupported_raw_falls_back_to_embedded_preview() { + let b = cr3_with_preview(48, 32); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // no preview at all: a clear error, not a panic + assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); +} + +/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's +/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a +/// library reopen, and Reload / Relink clear it once the file decodes (undoably). +#[test] +fn unsupported_raw_imports_as_preview_only_and_survives_reopen() { + use serde_json::json; + let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let path = files.join("DSC_0001.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // the import review already says so + let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}"); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + let why = p.preview_only.clone().expect("marked preview only"); + assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + // stamped with the version that decided it, so a later version rechecks it (#662) + assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); + assert!(!p.preview_only_stale()); + assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); + assert!(!p.develops_raw()); + // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults + assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); + assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); + assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); + // agents see it + let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap(); + assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}"); + let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap(); + assert_eq!(i["preview_only"], json!(why), "{i}"); + // a library reopen keeps it + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); + // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back + std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["reloaded"], json!([id.0]), "{r}"); + assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); + assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); + // relinking to a file that decodes clears it too + let dng = files.join("DSC_0001.dng"); + std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only, None); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif +/// declares a picture 25x its size. +fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); + ifd0.set_child( + t::EXIF_IFD, + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), + ); + let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); + if shell { + b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); + } + b +} + +/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size +/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't +/// decode: preview only, with the reason, still showing that image. +#[test] +fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { + let b = tiff_shell(24, 16, true); + let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); + let why = p.preview_only.expect("marked preview only"); + assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); + let (img, src) = load_bytes(&b, 12).unwrap(); + assert_eq!((img.width, img.height, src.raw), (12, 8, false)); + assert!(embedded_preview_srgb(&b, 12).is_some()); + // the same pixels without the private block are a small ordinary image + let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); + assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); + + // TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281) + for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + let px: Vec = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] }); + let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); + let p = probe_bytes("photo.tif", &bytes).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}"); + let (img, src) = load_bytes(&bytes, 24).unwrap(); + assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}"); + } +} + +/// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first +/// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. +#[test] +fn undecodable_container_with_a_preview_imports_as_preview_only() { + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + jpeg[0] = 0x02; + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.extend(std::iter::repeat_n(0x11u8, 300)); + f.extend_from_slice(&jpeg); + f.extend(std::iter::repeat_n(0x22u8, 300)); + let p = probe_bytes("A.MRW", &f).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30)); + assert!(p.preview_only.is_some_and(|w| w.contains("Mrw"))); + let (img, src) = load_bytes(&f, 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // without a JPEG: a clear error + let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err(); + assert!(e.contains("without an embedded preview"), "{e}"); +} + +/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` +/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. +#[test] +fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mos = |leaf_data: bool| { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); + if leaf_data { + ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); + } + TiffWriter::default().write(&[ifd0]).unwrap() + }; + let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); + assert!(p.preview_only.is_some_and(|why| !why.is_empty())); + let (img, src) = load_bytes(&mos(true), 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // the same grey image without Leaf's block is an ordinary 16-bit TIFF + let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); +} + +/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its +/// headers; loading it says what is wrong instead of naming a bare TIFF compression number. +#[test] +fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + ifd.set(t::COMPRESSION, Value::Short(vec![9])); + ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); + let f = TiffWriter::default().write(&[ifd]).unwrap(); + let p = probe_bytes("GOPR0001.GPR", &f).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16)); + let e = load_bytes(&f, 16).unwrap_err(); + assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}"); +} + +/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; +/// CR3 keeps its own wording. +#[test] +fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() { + use lightcraft_raw::RawError; + let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01"; + assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string())); + assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip"))); + assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok()); + assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok()); + assert!(preview_reason(b"x", RawError::NotRaw).is_err()); + assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 "))); +} + +#[test] +fn quick_jobs_show_embedded_previews_then_cached_renders() { + use crate::media::QuickSource; + let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + // loupe: an unedited raw opens on its embedded preview… + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Embedded)); + let img = q.rendered.unwrap().image; + assert_eq!((img.width, img.height), (96, 64)); + // …and once the loupe has rendered it, on that render (any size) + let full = s.loupe_job(id, 48, 32, true).unwrap().run(); + assert!(full.rendered.is_ok() && full.quick.is_none()); + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Cached)); + assert_eq!(q.rendered.unwrap().image.width, 48); + // grid: embedded first, then the real thumbnail (final, with the thumbnail job's key) + let job = s.thumb_job(id, 128).unwrap(); + let q = s.quick_thumb_job(&job).unwrap().run(); + assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key)); + drop(job.clone().run()); + assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached)); + // an edited raw: no embedded stand-in (it wouldn't show the edit) + s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap(); + let job = s.thumb_job(id, 128).unwrap(); + assert!(s.quick_thumb_job(&job).is_none()); + assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the +/// embedded preview can't be read (and nothing is cached) the quick job itself must render the +/// raw, or the cell stays empty for good ("no quick preview"). +#[test] +fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() { + use crate::media::QuickSource; + let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None)); + let job = s.thumb_job(id, 128).unwrap(); + let q = s.quick_thumb_job(&job).unwrap().run(); + assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key)); + assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` +/// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). +fn sony_arw(model: &str) -> Vec { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + // 3:2, the aspect Sony's lens opcodes are limited to + let (w, h) = (72u32, 48u32); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() +} + +/// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens +/// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. +#[test] +fn arw_with_lens_data_imports_with_profile_corrections_on() { + let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = crate::Session::new().with_fs(); + for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { + let path = dir.join(format!("{model}.arw")); + std::fs::write(&path, sony_arw(model)).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); + assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); + assert!(!p.is_edited(), "{model}: as imported"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's +/// opcodes), off when it doesn't. +#[test] +fn rw2_and_dng_defaults_unchanged() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + let s = crate::Session::new(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; + for format in ["RW2", "DNG"] { + for embedded in [None, Some(lens)] { + let name = format!("x.{format}"); + let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); + p.kind = MediaKind::Raw; + p.embedded_lens = embedded; + crate::import::apply_import_defaults(&s, &mut p); + assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); + } + } +} + +/// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read +/// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the +/// correction off keeps it off. +#[test] +fn existing_photo_settings_are_not_rewritten() { + use lightcraft_catalog::{Op, PhotoId}; + let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let import = |s: &mut crate::Session, name: &str| { + let path = files.join(name); + // trailing bytes of its own: not a duplicate of the other file + std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + PhotoId(r["imported"][0].as_u64().unwrap()) + }; + let old = import(&mut s, "old.arw"); + // as a release that didn't read the Sony tables catalogued it + let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); + d.optics.lens_profile = false; + let ops = vec![ + Op::SetEmbeddedLens { id: old, lens: None }, + Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, + ]; + s.commit("x", Op::Batch { ops }).unwrap(); + let off = import(&mut s, "off.arw"); + s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); + let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); + assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + for b in &before { + let p = s.catalog.photo(b.id).unwrap(); + assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); + } + assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index bb81bd240..6fe665896 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -32,1730 +32,31 @@ use serde::{Deserialize, Serialize}; use crate::Session; use crate::media::ProbeInfo; -/// 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. -pub const EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", - "srw", "psd", "psb", "jxl", "gif", "bmp", - // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as - // failed with the reason ("HEIC/HEIF support isn't included in this build"). - "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", -]; +mod commit; +mod defaults; +mod files; +mod job; +mod scan; +mod types; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum ImportMode { - /// Reference the files where they are. - #[default] - Add, - /// Copy the files into the library's `Originals/` folder. - Copy, - /// Move the files there: as Copy, then each original (and its sidecars) is removed from the - /// source once the copy is verified and catalogued. - Move, -} - -/// What an import does with a file the library already has in Recently Deleted. -/// -/// Someone who imports such a file wants the photo in the library again, so the default brings -/// it back (issues #298, #706: before, the import skipped it as "already in the library" while -/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits, -/// rating and albums come back with the photo and Reset Edits gives a clean start — where -/// importing afresh would silently drop them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum OnDeleted { - /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). - Skip, - /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose - /// file is no longer where the library had it is relinked to the imported file. - #[default] - Restore, - /// Delete the trashed record permanently and import the file as a new photo. - Fresh, -} - -impl OnDeleted { - pub fn parse(s: &str) -> Option { - match s { - "skip" => Some(Self::Skip), - "restore" => Some(Self::Restore), - "fresh" => Some(Self::Fresh), - _ => None, - } - } -} - -/// What an import does besides adding the photos. -#[derive(Clone, Debug, Default)] -pub struct ImportOptions { - /// A file that is in Recently Deleted: restore (default), skip, or import afresh. - pub on_deleted: OnDeleted, - pub mode: ImportMode, - /// Applied to every imported photo (one History entry). - pub preset: Option, - /// Added to every imported photo. - pub keywords: Vec, - /// Browsing a folder: photos come in as `local` (not in the library), and a file with the - /// same content as one already known is still listed. - pub local: bool, - /// Copy: where the copies go (default: the library's `Originals/`). - pub destination: Option, - /// Copy: the folders inside the destination. - pub organize: Organize, - /// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`, - /// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`. - pub rename: Option, - pub rename_start: usize, - /// A metadata preset (by name) applied to every imported photo. - pub metadata_preset: Option, - /// Copy: raws are copied as DNG (Copy as DNG). - pub convert_dng: bool, -} - -/// How copies are filed in the destination. The date is the capture time, else the time of the -/// import. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub enum Organize { - /// `YYYY/YYYY-MM-DD/` by capture date. - #[default] - ByDay, - /// `YYYY/YYYY-MM/`. - ByMonth, - /// All in the destination itself. - Flat, - /// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own - /// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`]; - /// never outside the destination). - Template(String), -} - -impl Organize { - /// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`). - pub fn parse(s: &str) -> Option { - match s { - "date" | "day" | "byDay" => Some(Organize::ByDay), - "month" | "byMonth" => Some(Organize::ByMonth), - "flat" | "none" | "intoOneFolder" => Some(Organize::Flat), - t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())), - _ => None, - } - } - - /// The folders (inside the destination) a copy of `p` goes to. - pub fn folders(&self, p: &Photo) -> Vec { - let date = p.date(); - let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); - let year = day.get(..4).unwrap_or("undated"); - match self { - Organize::ByDay => vec![year.into(), day.into()], - Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()], - Organize::Flat => Vec::new(), - Organize::Template(t) => crate::rename::expand_folder(t, p, 1), - } - } -} - -/// A file found by [`scan`], for the import review. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ImportCandidate { - pub path: String, - pub name: String, - pub format: String, - pub kind: MediaKind, - pub width: u32, - pub height: u32, - pub file_size: u64, - pub captured: Option, - /// `"path"` (already in the library), `"content"` (same bytes in the library or earlier in this - /// list): skipped by the import. - pub duplicate: Option, - /// The photo that already has it. - pub existing: Option, - /// Not readable. - pub error: Option, - /// A raw variant that can't be decoded yet (why): it imports as preview only. - #[serde(skip_serializing_if = "Option::is_none")] - pub preview_only: Option, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct Duplicate { - pub path: String, - /// The photo that already has these bytes (or this path). - pub existing: Option, - /// `"path"` (already imported from there) or `"content"` (same bytes elsewhere). - pub reason: &'static str, - /// That photo is in Recently Deleted (see [`OnDeleted`]). - #[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")] - pub existing_deleted: bool, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct ImportReport { - pub imported: Vec, - pub duplicates: Vec, - /// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub restored: Vec, - /// Move: the originals that were moved (and removed from the source). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub moved: Vec, - /// Move: originals (or sidecars) left at the source, and why — the photo may still have been - /// imported (from its copy, or in place). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub kept: Vec, - /// (path, error) - pub failed: Vec<(String, String)>, - /// Files found after expanding folders. - pub scanned: usize, - /// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP). - pub sidecars: usize, - /// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub warnings: Vec, -} - -/// A structured diagnostic from foreign XMP develop settings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ImportWarningCode { - /// A named camera profile was found but cannot be applied as Lightroom does. - CameraProfile, - /// Lightroom geometry data was found but its saved transform is not retained. - UprightGeometry, - /// One or more other XMP develop fields have no LightCraft mapping. - UnmappedXmp, -} - -/// The saved Lightroom Upright mode associated with a foreign geometry transform. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ImportUprightMode { - Off, - Auto, - Level, - Vertical, - Full, - Guided, - Unknown, -} - -/// One actionable XMP import diagnostic. `path` is filled when its photo is committed. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportWarning { - pub path: String, - pub code: ImportWarningCode, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub fields: Vec, -} - -/// A file moved by an import. -#[derive(Clone, Debug, Default, Serialize)] -pub struct Moved { - pub from: String, - pub to: String, - /// Its sidecars' new paths. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub sidecars: Vec, -} - -/// A source a Move left where it was. -#[derive(Clone, Debug, Default, Serialize)] -pub struct Kept { - pub path: String, - pub reason: String, -} - -/// 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 -/// camera intended); a user default preset ([`ImportDefaults`]) goes on top. -pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings { - p.import_defaults() -} - -/// Does the photo still look as the camera rendered it (its embedded camera preview is then a -/// fair stand-in)? Not when a default preset changed it on import. -pub fn has_import_look(p: &Photo) -> bool { - *p.develop == p.camera_defaults() -} - -/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`). -/// Presets are referenced by id; a preset that no longer exists is ignored. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ImportDefaults { - /// Preset applied to raw files (`None` = the LightCraft default). - pub raw_preset: Option, - /// Use a camera's own default (below) when the photo's camera has one. - pub per_camera: bool, - /// Per-camera raw defaults, keyed by the camera's make + model as shown in Info - /// (`"Canon EOS R5"`). - pub cameras: Vec, - /// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none). - pub other_preset: Option, - /// Copyright notice given to imported photos that don't carry one (empty = none). - pub copyright: String, - /// Creator given to imported photos that don't name one (empty = none). - pub creator: String, - /// Metadata preset applied to every imported photo (`metadata.*`; `None` = none). - pub metadata_preset: Option, - /// Auto import: a watched folder whose new photos are added as they arrive (`None` = off), - /// copied into the library's Originals instead of added in place when `auto_copy`, and an - /// album they go to. - pub auto_folder: Option, - pub auto_copy: bool, - pub auto_album: Option, - /// Open the import review when a camera or memory card is connected. - pub show_on_card: bool, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct CameraDefault { - /// Make + model (`Meta::camera`). - pub camera: String, - /// Preset id; `None` = the LightCraft default for this camera. - pub preset: Option, -} - -impl ImportDefaults { - /// The preset id that applies to a photo of this kind and camera, if any. - pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> { - if !raw { - return self.other_preset.as_deref(); - } - if self.per_camera - && let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera)) - { - return c.preset.as_deref(); - } - self.raw_preset.as_deref() - } -} - -/// Give a freshly imported photo its default settings: the camera defaults, then the matching -/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look. -pub fn apply_import_defaults(s: &Session, p: &mut Photo) { - // metadata defaults fill gaps only: the file's own copyright / creator win - let d = &s.import_defaults; - if p.meta.copyright.trim().is_empty() && !d.copyright.trim().is_empty() { - p.meta.copyright = d.copyright.trim().to_string(); - } - if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() { - p.meta.creator = d.creator.trim().to_string(); - } - if let Some(mp) = d.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { - crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); - } - let base = p.camera_defaults(); - // a raw shown from its embedded JPEG gets the rendered-file default, not the raw one - let raw = p.develops_raw(); - let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); - match preset { - Some(pr) => { - let look = std::sync::Arc::new(pr.apply(&base, 1.0)); - p.develop = look.clone(); - p.import_look = (*look != base).then_some(look); - } - None => { - p.develop = std::sync::Arc::new(base); - p.import_look = None; - } - } -} - -pub fn is_supported(path: &Path) -> bool { - path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) -} - -/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension -/// is listed in both cases: browsers' `accept` matching is case-insensitive per -/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and -/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws -/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker. -/// -/// The picker only offers files this build can actually import: `.avif` has no -/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs' -/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]). -/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised, -/// and import reports them as failed, saying why. -pub fn accept_list() -> String { - let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); - let mut out = String::new(); - for &ext in EXTENSIONS { - // Skip extensions a web user could pick but this build could not import. - let pickable = match ext { - "avif" => false, - "heic" | "heif" => heif_decodable, - _ => true, - }; - if !pickable { - continue; - } - if !out.is_empty() { - out.push(','); - } - out.push('.'); - out.push_str(ext); - out.push(','); - out.push('.'); - out.push_str(&ext.to_uppercase()); - } - out -} - -/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) -/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a -/// walk that stops at a bound is logged. -pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { - expand_within(paths, skip, crate::walk::Limits::default()).0 -} - -/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). -pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec, bool) { - expand_noting(paths, skip, limits, None) -} - -/// [`expand_within`], handing the listing of every folder that is walked to `note` -/// ([`crate::walk::files_in_noting`]). -pub(crate) fn expand_noting( - paths: &[String], - skip: Option<&Path>, - limits: crate::walk::Limits, - mut note: Option, -) -> (Vec, bool) { - let mut out = Vec::new(); - let mut truncated = false; - for p in paths { - let p = Path::new(p); - if skip.is_some_and(|s| p == s) { - continue; - } - if p.is_dir() { - let w = match note.as_mut() { - Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note), - None => crate::walk::files_in(p, skip, limits, is_supported), - }; - if w.truncated { - log::warn!( - "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", - p.display(), - w.entries, - limits.max_entries, - limits.max_depth - ); - } - truncated |= w.truncated; - out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string())); - } else { - // explicitly named files are attempted even with an unknown extension (sniffed) - out.push(p.to_string_lossy().to_string()); - } - } - let mut seen = std::collections::HashSet::new(); - out.retain(|p| seen.insert(p.clone())); - (out, truncated) -} - -/// What a path given on the command line at launch becomes ([`launch_path`]). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum LaunchPath { - /// Import this (absolute) file or folder, in the background. - Import(String), - /// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never - /// walked without being chosen in the app (issue #374). - Refused { path: String, why: String }, -} - -/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`); -/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is -/// resolved against `cwd` — the working folder a launcher picked, often a drive root or -/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive -/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or -/// refused, and so is the home folder `home`: either would walk most of the disk. -pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option) -> Option { - if arg.trim().is_empty() { - return None; - } - let given = Path::new(arg); - let joined = if given.is_absolute() { - given.to_path_buf() - } else { - match cwd { - Some(c) => c.join(given), - None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }), - } - }; - // `.` and `..` resolved by name (no file-system access) - let mut abs = PathBuf::new(); - for c in joined.components() { - match c { - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - abs.pop(); - } - other => abs.push(other.as_os_str()), - } - } - let shown = abs.to_string_lossy().to_string(); - if abs.parent().is_none() { - return Some(match dcim(&abs) { - Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()), - None => LaunchPath::Refused { path: shown, why: "a whole drive".into() }, - }); - } - let key = lightcraft_catalog::query::folder_key; - if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) { - return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() }); - } - Some(LaunchPath::Import(shown)) -} - -/// Probe files (headers + content hash) as an import would. -pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { - let progress = ScanProgress::default(); - probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) -} - -/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread. -pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec> { - let progress = ScanProgress::default(); - probe_all(Some(probe), paths, &progress.done, &progress.cancel) -} - -/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once -/// `cancel` is set: the files not reached come back as errors. -pub(crate) fn probe_all( - probe: Option<&crate::media::FileProbe>, - paths: &[String], - done: &std::sync::atomic::AtomicUsize, - cancel: &std::sync::atomic::AtomicBool, -) -> Vec> { - use std::sync::atomic::Ordering::Relaxed; - let Some(probe) = probe.cloned() else { - return paths - .iter() - .map(|p| { - Ok(ProbeInfo { - format: Path::new(p).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(), - ..Default::default() - }) - }) - .collect(); - }; - #[cfg(not(target_arch = "wasm32"))] - { - let n = workers(paths.len()); - if n > 1 { - let mut results: Vec>> = vec![None; paths.len()]; - let next = std::sync::atomic::AtomicUsize::new(0); - let out = std::sync::Mutex::new(&mut results); - std::thread::scope(|sc| { - for _ in 0..n { - sc.spawn(|| { - loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if i >= paths.len() { - break; - } - if cancel.load(Relaxed) { - break; - } - let r = probe(&paths[i]); - done.fetch_add(1, Relaxed); - out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); - } - }); - } - }); - return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect(); - } - } - paths - .iter() - .map(|p| { - if cancel.load(Relaxed) { - return Err("cancelled".into()); - } - let r = probe(p); - done.fetch_add(1, Relaxed); - r - }) - .collect() -} - -/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the -/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and -/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte -/// with the source when there is none); a bad one is removed and the photo reported as failed. -fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result { - let name = name - .map(str::to_string) - .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); - let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse); - crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string()) -} - -/// 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). -pub struct ScanInput { - probe: Option, - skip: Option, - /// The library as it is now (photos are shared with the session, not copied): looked up for - /// the files the scan finds ([`library_files`]) and the contents it probes - /// ([`library_hashes`]), on the scan's thread. - library: std::sync::Arc, - cache: HashMap, -} - -/// The library's photos of `files`: path → (photo, its summary as a candidate that is there -/// already). One pass over the library, and a record only for a file that was found: a folder of -/// a hundred files in a library of millions costs a hundred records. -fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap { - let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect(); - let mut by_path = HashMap::new(); - if found.is_empty() { - return by_path; - } - for p in library.photos() { - if let Source::File { path } = &p.source - && found.contains(Path::new(path)) - { - let c = ImportCandidate { - path: path.clone(), - format: p.format.clone(), - kind: p.kind, - width: p.width, - height: p.height, - file_size: p.file_size, - captured: p.captured.clone(), - duplicate: Some("path".into()), - existing: Some(p.id.0), - ..Default::default() - }; - by_path.insert(PathBuf::from(path), (p.id, c)); - } - } - by_path -} - -/// Which photo of the library has each of the contents `hashes` (the later one where several -/// have it). One pass over the library, and an entry only for a content that was asked about. -fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator) -> HashMap { - let wanted: std::collections::HashSet<&str> = hashes.collect(); - let mut by_hash = HashMap::new(); - if wanted.is_empty() { - return by_hash; - } - for p in library.photos() { - if let Some(h) = &p.content_hash - && wanted.contains(h.as_str()) - { - by_hash.insert(h.clone(), p.id); - } - } - by_hash -} - -/// Progress and cancellation of a running [`scan_with`]. -#[derive(Default)] -pub struct ScanProgress { - /// Files to probe, set once the folders are expanded. - pub total: std::sync::atomic::AtomicUsize, - pub done: std::sync::atomic::AtomicUsize, - /// Shared with the scan's row in the activity stack (its ✕ sets it). - pub cancel: std::sync::Arc, -} - -/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows. -pub struct ScanOutput { - pub candidates: Vec, - pub probes: HashMap, -} - -impl ScanInput { - pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec) { - let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - // (no table of the library here: this is the session's thread, and the library may hold - // millions of photos. The scan looks up what it finds) - let library = std::sync::Arc::new(s.catalog.clone()); - let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) }; - (input, paths.to_vec()) - } - - /// The folder a scan never enters (the library's own). - pub(crate) fn skip(&self) -> Option<&Path> { - self.skip.as_deref() - } - - /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one). - pub(crate) fn library(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.library) - } -} - -/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content -/// against the library and earlier candidates). Nothing is added; the probes are kept for the -/// import that follows. -pub fn scan(s: &mut Session, paths: &[String]) -> Vec { - let (input, paths) = ScanInput::new(s, paths); - let out = scan_with(input, &paths, &ScanProgress::default()); - s.import_probes = out.probes; - out.candidates -} - -/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it -/// has) when `progress.cancel` is set. -pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { - let files = expand(paths, input.skip.as_deref()); - scan_files(input, files, progress) -} - -/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]). -pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &ScanProgress) -> ScanOutput { - use std::sync::atomic::Ordering::Relaxed; - let by_path = library_files(&input.library, &files); - let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); - progress.total.store(todo.len(), Relaxed); - // probes from a preceding `scan` are reused when the file is unchanged (same size) - let cached: Vec> = todo - .iter() - .map(|f| { - let info = input.cache.remove(f)?; - // (✕ set while the files' cached probes are checked: no more round trips to the disk; what is - // not reached counts as not probed, like the files after it) - if progress.cancel.load(Relaxed) { - return None; - } - let size = std::fs::metadata(f).map(|m| m.len()).ok(); - (size.is_none() || size == Some(info.file_size)).then_some(info) - }) - .collect(); - let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); - progress.done.store(todo.len() - missing.len(), Relaxed); - let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); - let probed: Vec> = - cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); - crate::memory::release(); - let mut probes: HashMap> = todo.into_iter().zip(probed).collect(); - // (who in the library has a content that was probed: asked once the probes are in) - let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref()); - let mut seen_hash: HashMap> = - library_hashes(&input.library, probed_hashes).into_iter().map(|(h, id)| (h, Some(id.0))).collect(); - let mut out = Vec::with_capacity(files.len()); - let mut kept = HashMap::new(); - for f in files { - if let Some((_, c)) = by_path.get(Path::new(&f)) { - let mut c = c.clone(); - c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); - out.push(c); - continue; - } - let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); - let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() }; - match probes.remove(&f) { - Some(Ok(info)) => { - (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = - (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); - // as the import will date it (a sidecar's date, when there is one, still wins there) - c.captured = c.captured.or_else(|| info.file_modified.clone()); - c.preview_only = info.preview_only.clone(); - if let Some(h) = &info.content_hash { - match seen_hash.get(h) { - Some(existing) => { - c.duplicate = Some("content".into()); - c.existing = *existing; - } - None => { - seen_hash.insert(h.clone(), None); - } - } - } - kept.insert(f, info); - } - Some(Err(e)) => c.error = Some(e), - None => c.error = Some("not probed".into()), - } - out.push(c); - } - ScanOutput { candidates: out, probes: kept } -} - -/// Import files/folders. See the module docs. -pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { - import_with(s, paths, &ImportOptions { mode, ..Default::default() }) -} - -/// Import files/folders with options (preset, keywords). See the module docs. -/// -/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars, -/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can -/// run the first on a worker thread; here both run in turn. -pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result { - let mut job = ImportJob::new(s, opts.clone())?; - let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false)); - // probes a scan left for files not imported now stay for a later import - s.import_probes.extend(std::mem::take(&mut job.cache)); - commit_prepared(s, &job.opts, &job.now, prepared) -} - -/// Everything the file-system half of an import needs, detached from the session so it can run -/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`]; -/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting, -/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session. -pub struct ImportJob { - pub opts: ImportOptions, - /// The mode in effect (Copy / Move become Add in a library without its own folder). - mode: ImportMode, - lib_dir: Option, - copy_root: Option, - /// The library as it was when the import was set up, until it is looked up - /// ([`ImportJob::look_up_library`], with the first files): setting an import up is the - /// session's thread, and a table of a library of millions of photos takes seconds. With it, - /// whether a photo in Recently Deleted is passed over (an import that replaces such photos, - /// as the options said when the import was set up). - library: Option<(lightcraft_catalog::Catalog, bool)>, - /// path → (photo, a Local browse record?) - by_path: HashMap, - /// content hash → the photo that has it (`None`: a file earlier in this import) - by_hash: HashMap>, - probe: Option, - file_bytes: Option, - /// Probes from a preceding scan (not read again). - cache: HashMap, - naming: crate::sidecar::SidecarNaming, - now: String, - seq: usize, - /// Files found so far (folders expanded) and handled so far, for progress. - pub total: std::sync::Arc, - pub done: std::sync::Arc, - /// Of those, the files that have to be read first (not probed by a preceding scan) and the - /// ones read so far: the step before they are handled. - pub read_total: std::sync::Arc, - pub read_done: std::sync::Arc, -} - -/// The outcome of [`ImportJob::prepare`] for one batch, in file order. -#[derive(Debug, Default)] -pub struct Prepared { - pub scanned: usize, - items: Vec, -} - -#[derive(Debug)] -enum PreparedItem { - /// A Local (browsed) record imported for real. - Promote(PhotoId), - Duplicate { - path: String, - existing: Option, - reason: &'static str, - hash: Option, - }, - Failed(String, String), - Kept(Kept), - Ready(Box), -} - -#[derive(Debug)] -struct ReadyFile { - path: String, - stored: String, - info: ProbeInfo, - sidecar: Option, - placed: Option, -} - -impl Prepared { - /// Undo what the batch did on disk that only a commit would make permanent: files placed by a - /// Move (their sources were never touched). Copies stay, like copies of a failed commit. - pub fn rollback(&self) { - for it in &self.items { - if let PreparedItem::Ready(r) = it - && let Some(pl) = &r.placed - { - crate::import_move::rollback(pl); - } - } - } - - /// Entries in the batch (after expanding folders). - pub fn len(&self) -> usize { - self.items.len() - } - - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - /// Recheck Add-only entries against the owner catalog immediately before commit. - /// Preparation may have run while another import added the same path or content. - pub fn revalidate_add(&mut self, s: &Session) { - let mut paths = HashMap::new(); - let mut hashes = HashMap::new(); - for photo in s.catalog.photos() { - if let Source::File { path } = &photo.source { - paths.insert(normalize_import_path(path), photo.id); - } - if let Some(hash) = &photo.content_hash { - hashes.insert(hash.clone(), photo.id); - } - } - let mut items = Vec::with_capacity(self.items.len()); - for item in std::mem::take(&mut self.items) { - let PreparedItem::Ready(ready) = item else { - items.push(item); - continue; - }; - let ReadyFile { path, stored, info, sidecar, placed } = *ready; - let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied()); - let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied()); - if let Some(existing) = existing_path.or(existing_hash) { - if let Some(placed) = &placed { - crate::import_move::rollback(placed); - } - items.push(PreparedItem::Duplicate { - path, - existing: Some(existing), - reason: if existing_path.is_some() { "path" } else { "content" }, - hash: info.content_hash, - }); - } else { - items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); - } - } - self.items = items; - } -} - -fn normalize_import_path(path: &str) -> String { - let path = path.replace('\\', "/"); - if cfg!(windows) { path.to_lowercase() } else { path } -} - -impl ImportJob { - /// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes - /// the probes a preceding scan left ([`Session::import_probes`]). - pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result { - let mode = opts.mode; - // Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of - // every added file in its own storage already, so "copy" there means "add". - let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move); - let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode }; - if mode == ImportMode::Move && opts.local { - return Err(crate::EngineError::Other("browsing a folder never moves its files".into())); - } - let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - // where copies go: the chosen folder, else the library's Originals/ - let copy_root = opts - .destination - .as_deref() - .filter(|d| !d.trim().is_empty()) - .map(std::path::PathBuf::from) - .or_else(|| lib_dir.as_ref().map(|l| l.join("Originals"))); - if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() { - return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into())); - } - let fresh = opts.on_deleted == OnDeleted::Fresh; - Ok(ImportJob { - seq: opts.rename_start.max(1), - opts, - mode, - lib_dir, - copy_root, - library: Some((s.catalog.clone(), fresh)), - by_path: HashMap::new(), - by_hash: HashMap::new(), - probe: s.media.file_probe.clone(), - file_bytes: s.media.file_bytes.clone(), - cache: std::mem::take(&mut s.import_probes), - naming: s.xmp.naming, - now: (s.clock)(), - total: Default::default(), - done: Default::default(), - read_total: Default::default(), - read_done: Default::default(), - }) - } - - /// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip - /// duplicates by content, read sidecars, copy or place (Move) the files — several at once, - /// with the names and outcomes of one after another. Nothing in the catalog changes. Stops - /// before the next file once `cancel` is set. - pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared { - let files = self.expand(paths); - self.prepare_files(files, cancel) - } - - /// Expand `paths` (folders recursively, the library's own folder skipped) into the files an - /// import would handle; counted in [`ImportJob::total`]. - pub fn expand(&self, paths: &[String]) -> Vec { - let files = expand(paths, self.lib_dir.as_deref()); - self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed); - files - } - - /// When the import started ([`Session::clock`]), for [`commit_prepared`]. - pub fn now(&self) -> &str { - &self.now - } - - /// What the library held when the import was set up, by path and by content: made once, with - /// the first files (on the thread that prepares them), from the library as it was then. The - /// snapshot comes back: the files the import is adding are ranked against it for the ownership - /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). - fn look_up_library(&mut self) -> Option { - let (library, fresh) = self.library.take()?; - for p in library.photos() { - // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate - if p.deleted && fresh { - continue; - } - if let Source::File { path } = &p.source { - self.by_path.insert(path.clone(), (p.id, p.local)); - } - if let Some(h) = &p.content_hash { - self.by_hash.insert(h.clone(), Some(p.id)); - } - } - Some(library) - } - - /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. - pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { - use std::sync::atomic::Ordering::Relaxed; - // the library as it was, kept for the ownership of the shared stem sidecars below - let library = self.look_up_library(); - let opts = self.opts.clone(); - let mode = self.mode; - let mut out = Prepared { scanned: files.len(), items: Vec::new() }; - let mut todo = Vec::new(); - for f in files { - match self.by_path.get(f.as_str()).copied() { - Some((id, true)) if !opts.local => { - // a file that was only browsed (Local) joins the library when it is imported for real - self.by_path.insert(f, (id, false)); - out.items.push(PreparedItem::Promote(id)); - self.done.fetch_add(1, Relaxed); - } - Some((id, _)) => { - out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None }); - self.done.fetch_add(1, Relaxed); - } - None => todo.push(f), - } - } - - // files a preceding scan already probed are not read again (a network share is slow to read), - // as long as they are still there, as they were: a file gone or changed since is probed again - let cached: Vec> = todo - .iter() - .map(|f| { - // (the web's files are not std::fs files: kept as probed) - self.cache - .remove(f) - .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) - }) - .collect(); - let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); - // Reading the files is the slow part of adding them where they are: it is counted on its - // own, and `cancel` stops it too (a large library takes long enough to want both). - self.read_total.fetch_add(missing.len(), Relaxed); - let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter(); - let probed: Vec> = - cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); - crate::memory::release(); - // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones - // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar - // and never the raw's packet (issue #1905). - let stems = crate::sidecar::ImportStems::for_import( - library.as_ref(), - todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), - ); - // Files are planned one by one, in order (duplicates, sidecars, names, folders); their - // copies or moves — the slow part, reading and writing every byte and verifying it — are - // collected and run on several threads (issue #367), then their outcomes take their places. - let mut plan = Plan::default(); - for (path, info) in todo.into_iter().zip(probed) { - if cancel.load(Relaxed) { - break; - } - let mut info = match info { - Ok(i) => i, - Err(e) => { - log::warn!("import {path}: {e}"); - self.done.fetch_add(1, Relaxed); - plan.slots.push(Slot::Item(PreparedItem::Failed(path, e))); - continue; - } - }; - // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds - if !opts.local - && let Some(h) = &info.content_hash - && plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h)) - { - self.run_plan(&mut plan, &mut out, cancel); - if cancel.load(Relaxed) { - break; - } - } - if let Some(h) = &info.content_hash - && !opts.local - && let Some(existing) = self.by_hash.get(h) - { - self.done.fetch_add(1, Relaxed); - plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) })); - continue; - } - // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files - // and names the copy when the file itself has none - let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) - .and_then(|f| std::fs::read_to_string(f).ok()) - .or_else(|| info.xmp.clone().filter(|_| raw)); - let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); - let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { - Ok(sc) => Some(sc), - Err(e) => { - log::warn!("import {path}: XMP: {e}"); - None - } - }); - if info.captured.is_none() { - info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); - } - // no date in the file or its sidecar: the file's own date, as Lightroom does - if info.captured.is_none() { - info.captured = info.file_modified.clone(); - } - let stored = match (mode, &self.copy_root) { - (ImportMode::Copy | ImportMode::Move, Some(root)) - if !crate::import_move::inside(Path::new(&path), root) - && !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) => - { - // the templates see the photo as it will be catalogued (undated: the import time) - let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now); - q.captured = info.captured.clone(); - q.meta = info.meta.clone(); - let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| { - let n = crate::rename::expand(t, &q, self.seq); - self.seq += 1; - n - }); - let folders = opts.organize.folders(&q); - let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); - // the name this file gets depends on what a pending transfer of a like-named file - // into the same folder ends up with: that one goes first - let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() }); - let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named)); - if plan.transfers.iter().any(|t| t.family == family) { - self.run_plan(&mut plan, &mut out, cancel); - if cancel.load(Relaxed) { - break; - } - } - let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { - How::Place { name: name.unwrap_or(own) } - } else { - if mode == ImportMode::Move { - let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; - plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); - } - How::Copy { name } - }; - // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) - let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG"); - plan.slots.push(Slot::Transfer); - plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family }); - continue; - } - (ImportMode::Move, _) => { - let reason = "already inside the destination or the library: added where it is"; - plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); - path.clone() - } - _ => path.clone(), - }; - if let Some(h) = &info.content_hash { - self.by_hash.insert(h.clone(), None); - } - self.done.fetch_add(1, Relaxed); - plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None })))); - } - self.run_plan(&mut plan, &mut out, cancel); - out - } - - /// Run the transfers `plan` collected (on several threads) and append its items to `out` in - /// file order. Transfers not started because `cancel` was set leave no item. - fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) { - let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter(); - for slot in std::mem::take(&mut plan.slots) { - let item = match slot { - Slot::Item(it) => it, - Slot::Transfer => match done.next().flatten() { - Some(it) => it, - None => continue, - }, - }; - if let PreparedItem::Ready(r) = &item - && let Some(h) = &r.info.content_hash - { - self.by_hash.insert(h.clone(), None); - } - out.items.push(item); - } - } -} - -/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). -#[derive(Default)] -struct Plan { - slots: Vec, - transfers: Vec, -} - -enum Slot { - Item(PreparedItem), - /// The outcome of the next of [`Plan::transfers`]. - Transfer, -} - -/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]). -struct Transfer { - path: String, - info: ProbeInfo, - sidecar: Option, - dir: PathBuf, - how: How, - /// Copy as DNG. - dng: bool, - /// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's - /// names, so they never run at the same time. - family: (String, String), -} - -enum How { - /// A verified copy, under `name` (default: the file's own). - Copy { name: Option }, - /// Move: placed (linked or copied) without touching the source. - Place { name: String }, -} - -/// The names a file called `name` could end up with, as one key: the transfers number a taken name -/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`, -/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems). -fn name_family(name: &str) -> String { - let mut stem = match name.rsplit_once('.') { - Some((s, _)) if !s.is_empty() => s, - _ => name, - }; - while let Some((head, tail)) = stem.rsplit_once('-') - && !head.is_empty() - && !tail.is_empty() - && tail.bytes().all(|b| b.is_ascii_digit()) - { - stem = head; - } - stem.to_lowercase() -} - -/// Threads for `jobs` file reads (probes, copies) at once. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn workers(jobs: usize) -> usize { - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) -} - -/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]): -/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few -/// enough that each batch soon joins the catalog. -pub fn batch_size() -> usize { - #[cfg(not(target_arch = "wasm32"))] - { - workers(usize::MAX).saturating_mul(2).clamp(8, 16) - } - #[cfg(target_arch = "wasm32")] - { - 8 - } -} - -/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one -/// not started because `cancel` was set. `done` counts each one finished. -fn run_transfers( - transfers: Vec, - file_bytes: Option<&crate::merge::ByteReader>, - cancel: &std::sync::atomic::AtomicBool, - done: &std::sync::atomic::AtomicUsize, -) -> Vec> { - use std::sync::atomic::Ordering::Relaxed; - let one = |t: Transfer| { - if cancel.load(Relaxed) { - return None; - } - let path = t.path.clone(); - // last-resort guard: a panic is this file's failure, not the import's - let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e)); - done.fetch_add(1, Relaxed); - Some(item) - }; - #[cfg(not(target_arch = "wasm32"))] - { - let n = workers(transfers.len()); - if n > 1 { - let mut results: Vec> = std::iter::repeat_with(|| None).take(transfers.len()).collect(); - let queue = std::sync::Mutex::new(transfers.into_iter().enumerate()); - let out = std::sync::Mutex::new(&mut results); - // (the tests' injected faults hold for the workers too) - #[cfg(test)] - let fault = crate::import_move::injected_fault(); - let work = &|| { - #[cfg(test)] - crate::import_move::inject(fault); - loop { - let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); - let Some((i, t)) = next else { break }; - let r = one(t); - if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { - *slot = r; - } - } - }; - std::thread::scope(|sc| { - let handles: Vec<_> = - (0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect(); - if handles.is_empty() { - // no thread to be had: copy here, one after another - work(); - } - for h in handles { - if h.join().is_err() { - log::error!("import: a copy worker stopped unexpectedly"); - } - } - }); - return results; - } - } - transfers.into_iter().map(one).collect() -} - -/// Copy or move one planned file (and Copy as DNG); its item. -fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem { - let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t; - let mut placed = None; - let mut stored = match how { - How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) { - Ok(pl) => { - let dst = pl.dst.to_string_lossy().to_string(); - placed = Some(pl); - dst - } - Err(e) => return PreparedItem::Failed(path, e), - }, - How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) { - Ok(p) => p, - Err(e) => return PreparedItem::Failed(path, e), - }, - }; - if dng { - // decoding the raw twice and encoding it takes several times the file's size in memory - let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6); - let _permit = crate::memory::work_gate().acquire(weight); - match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) { - Ok(dng) => { - let _ = std::fs::remove_file(&stored); - stored = dng; - info.format = "DNG".into(); - } - Err(e) => log::warn!("import {path}: copy as DNG: {e}"), - } - } - PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) -} - -/// A trashed photo's file is not where the library had it (never on the web, where paths are -/// storage keys, not files). -fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool { - !cfg!(target_arch = "wasm32") - && match cat.photo(id).map(|p| &p.source) { - Some(Source::File { path }) => !Path::new(path).exists(), - _ => false, - } -} - -/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; -/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are -/// taken back. -pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result { - let now = now.to_string(); - let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() }; - let mut ops = Vec::new(); - // Move: placed at the destination, sources untouched until the records are committed - let mut placed: Vec = Vec::new(); - // photos added by this batch, by content (a duplicate of a file earlier in the import names it) - let mut new_hash: HashMap = HashMap::new(); - // Fresh: trashed photos a new import replaces (by content or by file), each removed once - let (mut trash_hash, mut trash_path): (HashMap, HashMap) = Default::default(); - if opts.on_deleted == OnDeleted::Fresh { - for p in s.catalog.photos().filter(|p| p.deleted) { - if let Some(h) = &p.content_hash { - trash_hash.insert(h.clone(), p.id); - } - if let Source::File { path } = &p.source { - trash_path.insert(path.clone(), p.id); - } - } - } - let mut purge: Vec = Vec::new(); - let mut purged: std::collections::HashSet = Default::default(); - for it in prepared.items { - match it { - PreparedItem::Promote(id) => { - if s.catalog.photo(id).is_some_and(|p| p.local) { - ops.push(Op::SetLocal { id, local: false }); - } - report.imported.push(id.0); - } - PreparedItem::Duplicate { path, existing, reason, hash } => { - let existing = existing.or_else(|| { - let h = hash?; - new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id)) - }); - let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); - match trashed { - // (browsing a folder in Local only looks: it never restores) - Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => { - if !report.restored.contains(&id.0) { - ops.push(Op::SetDeleted { id, deleted: false }); - // matched by content from another path, and the file the record - // points at is gone (moved, renamed): the photo follows the file - if reason == "content" && trashed_file_is_gone(&s.catalog, id) { - ops.push(crate::cmd::missing::relink_op(id, &path)); - } - report.restored.push(id.0); - } - } - _ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }), - } - } - PreparedItem::Failed(path, e) => report.failed.push((path, e)), - PreparedItem::Kept(k) => report.kept.push(k), - PreparedItem::Ready(r) => { - let ReadyFile { path, stored, info, sidecar, placed: pl } = *r; - placed.extend(pl); - let id = s.catalog.alloc_photo_id(); - if let Some(h) = &info.content_hash { - new_hash.insert(h.clone(), id); - } - for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) { - if purged.insert(*old) { - purge.push(*old); - } - } - let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default()); - // a copy is catalogued under its new name - let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); - let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now); - p.kind = info.kind; - p.file_size = info.file_size; - p.captured = info.captured; - p.meta = info.meta; - p.as_shot_wb = info.as_shot_wb; - p.content_hash = info.content_hash; - p.embedded_lens = info.embedded_lens; - p.set_preview_only(info.preview_only.clone()); - apply_import_defaults(s, &mut p); - if let Some(sc) = &sidecar { - crate::sidecar::merge_into(&mut p, sc, &now); - report.sidecars += 1; - report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| { - warning.path = path.clone(); - warning - })); - } - if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { - crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); - } - for k in &opts.keywords { - let k = lightcraft_catalog::keywords::clean(k); - if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) { - p.meta.keywords.push(k); - } - } - if let Some(preset) = &opts.preset { - let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0)); - let label = format!("Preset: {}", preset.name); - p.develop = d.clone(); - p.edited = Some(now.clone()); - p.history.push(lightcraft_catalog::HistoryStep { label, settings: d }); - } - report.imported.push(id.0); - p.local = opts.local; - if opts.local { - // the state as browsed: a later change keeps the record from being forgotten - p.set_local_baseline(); - } - ops.push(Op::AddPhoto { photo: Box::new(p) }); - } - } - } - // (built against the catalog as it is: one op list for all of them, ahead of the additions) - if !purge.is_empty() { - ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge)); - } - let log0 = s.pending_log.len(); - let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() { - ("Restore", report.restored.len()) - } else { - ("Add", report.imported.len().max(1)) - }; - if !ops.is_empty() - && let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops }) - { - // nothing was catalogued: take the moves back (their sources were never touched) - placed.iter().for_each(crate::import_move::rollback); - return Err(e); - } - if !placed.is_empty() { - finish_moves(s, placed, log0, &mut report); - } - Ok(report) -} - -/// Move, after the commit: save the library, then remove each source whose destination is -/// still intact. Nothing is removed when the library can't be saved. -fn finish_moves(s: &mut Session, placed: Vec, log0: usize, report: &mut ImportReport) { - // (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here) - let auto: Vec = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() }; - let before = s.pending_log.len(); - if let Err(e) = s.persist() { - for p in placed { - let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display()); - report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason }); - } - return; - } - for p in placed { - let from = p.src.to_string_lossy().to_string(); - match crate::import_move::finish(&p) { - Ok(kept) => { - let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect(); - report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars }); - report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason })); - } - Err(reason) => { - log::warn!("import: move {from}: {reason}"); - report.kept.push(Kept { path: from, reason }); - } - } - } - if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() { - s.auto_write_sidecars(&auto); - } -} - -/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock -/// offset); for [`Session::clock`] on native hosts. -pub fn system_clock() -> String { - #[cfg(not(target_arch = "wasm32"))] - { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - civil(secs) - } - #[cfg(target_arch = "wasm32")] - { - "2026-01-01T00:00:00".to_string() - } -} - -/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian. -pub fn civil(secs: i64) -> String { - lightcraft_catalog::dates::civil(secs) -} - -impl Session { - /// A thumbnail for an import candidate (not in the catalog): a raw's embedded preview, else a - /// small render of the file. `slot` makes the job's photo id unique (`u64::MAX - slot`). - pub fn candidate_thumb_job(&mut self, c: &ImportCandidate, edge: usize, slot: u64) -> Option { - if c.error.is_some() { - return None; - } - let id = PhotoId(u64::MAX - slot); - let mut p = Photo::new(id, Source::File { path: c.path.clone() }, &c.name, &c.format, c.width.max(1), c.height.max(1), ""); - p.kind = c.kind; - if let Some(info) = self.import_probes.get(&c.path) { - p.as_shot_wb = info.as_shot_wb; - p.embedded_lens = info.embedded_lens; - p.preview_only = info.preview_only.clone(); - } - p.develop = std::sync::Arc::new(p.import_defaults()); - let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge()); - let level = crate::media::SourceLevel::Thumb; - let source = self.media.origin_ref(&p.source, level.max_edge()); - let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64; - let small = crate::media::RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: None, - photo: id, - level, - source, - origin: p.source.clone(), - info: crate::media::source_info(&p), - settings: p.develop.clone(), - request: lightcraft_pipeline::RenderRequest::fit(edge, edge), - key, - cache: None, - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut: None, - display_fit: None, - }; - let embedded = match (&self.media.preview_loader, c.kind) { - (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), - _ => None, - }; - Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) }) - } - - /// Use the system clock for import/edit times (native hosts). - pub fn with_system_clock(mut self) -> Self { - self.clock = Box::new(system_clock); - self - } -} +pub(crate) use commit::workers; +pub use commit::{batch_size, civil, commit_prepared, system_clock}; +pub use defaults::{CameraDefault, ImportDefaults, apply_import_defaults, has_import_look, import_defaults}; +pub use files::{EXTENSIONS, LaunchPath, accept_list, expand, expand_within, is_supported, launch_path}; +pub(crate) use files::{expand_noting, probe_all, probe_paths, probe_with}; +#[cfg(test)] +use job::PreparedItem; +pub use job::{ImportJob, Prepared, import, import_with}; +pub(crate) use scan::scan_files; +pub use scan::{ScanInput, ScanOutput, ScanProgress, scan, scan_with}; +#[cfg(test)] +use scan::{library_files, library_hashes}; +pub use types::{ + Duplicate, ImportCandidate, ImportMode, ImportOptions, ImportReport, ImportUprightMode, ImportWarning, ImportWarningCode, Kept, Moved, OnDeleted, + Organize, +}; #[cfg(test)] #[path = "import/tests_known.rs"] mod tests_known; - -#[cfg(test)] -mod prepared_tests { - use super::*; - - /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the - /// files this build cannot import (see [`accept_list`]). - fn pickable_extensions() -> Vec<&'static str> { - let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); - EXTENSIONS - .iter() - .copied() - .filter(|ext| match *ext { - "avif" => false, - "heic" | "heif" => heif_decodable, - _ => true, - }) - .collect() - } - - #[test] - fn accept_list_covers_every_pickable_extension_in_both_cases() { - let list = accept_list(); - for ext in pickable_extensions() { - assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list"); - assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase()); - } - } - - /// The web picker must not offer files this build cannot import: `.avif` has - /// no AV1 decoder in any build, so it is never listed, in either case. - #[test] - fn accept_list_never_offers_avif() { - let list = accept_list(); - assert!(!list.contains(".avif"), "{list}"); - assert!(!list.contains(".AVIF"), "{list}"); - } - - /// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases) - /// only when this build can decode them; otherwise hidden, so a web user - /// cannot pick a file that would fail to import. - #[test] - fn accept_list_offers_heic_heif_only_when_decodable() { - let list = accept_list(); - let listed = lightcraft_codecs::Format::Heif.can_decode(); - for ext in ["heic", "heif"] { - assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}"); - assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}"); - } - } - - #[test] - fn revalidate_add_only_converts_matching_ready_item() { - let mut s = Session::new(); - let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); - let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); - let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); - s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); - let ready = |path: &Path| ReadyFile { - path: path.to_string_lossy().into(), - stored: path.to_string_lossy().into(), - info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, - sidecar: None, - placed: None, - }; - let mut prepared = Prepared { - scanned: 2, - items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], - }; - prepared.revalidate_add(&s); - assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); - assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); - } - - fn unread_paths(n: usize) -> Vec { - (0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect() - } - - /// Reading the files of a large import (a Lightroom catalog's originals) reported nothing: - /// the progress stood still at "14 of 24554" for as long as it took. - #[test] - fn prepare_counts_the_files_it_reads() { - use std::sync::atomic::Ordering::Relaxed; - let mut s = Session::new(); - let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))); - let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); - let (done, last) = (job.read_done.clone(), done_in_probe.clone()); - job.probe = Some(std::sync::Arc::new(move |_: &str| { - seen_in_probe.fetch_add(1, Relaxed); - last.fetch_max(done.load(Relaxed), Relaxed); - Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) - })); - let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false)); - assert_eq!(prepared.items.len(), 40); - assert_eq!(seen.load(Relaxed), 40); - assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read"); - assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted"); - assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own"); - } - - /// Cancel had no effect until every file had been read. - #[test] - fn cancel_stops_reading_files() { - use std::sync::atomic::Ordering::Relaxed; - let mut s = Session::new(); - let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); - let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone()); - job.probe = Some(std::sync::Arc::new(move |_: &str| { - seen_in_probe.fetch_add(1, Relaxed); - cancel_in_probe.store(true, Relaxed); - Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) - })); - let prepared = job.prepare(&unread_paths(400), &cancel); - // at most the files the (up to 8) readers had already started - assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); - assert!(prepared.items.is_empty()); - } -} diff --git a/crates/engine/src/import/commit.rs b/crates/engine/src/import/commit.rs new file mode 100644 index 000000000..bed8356fb --- /dev/null +++ b/crates/engine/src/import/commit.rs @@ -0,0 +1,371 @@ +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::*; + +/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). +#[derive(Default)] +pub(super) struct Plan { + pub(super) slots: Vec, + pub(super) transfers: Vec, +} + +pub(super) enum Slot { + Item(PreparedItem), + /// The outcome of the next of [`Plan::transfers`]. + Transfer, +} + +/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]). +pub(super) struct Transfer { + pub(super) path: String, + pub(super) info: ProbeInfo, + pub(super) sidecar: Option, + pub(super) dir: PathBuf, + pub(super) how: How, + /// Copy as DNG. + pub(super) dng: bool, + /// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's + /// names, so they never run at the same time. + pub(super) family: (String, String), +} + +pub(super) enum How { + /// A verified copy, under `name` (default: the file's own). + Copy { name: Option }, + /// Move: placed (linked or copied) without touching the source. + Place { name: String }, +} + +/// The names a file called `name` could end up with, as one key: the transfers number a taken name +/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`, +/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems). +pub(super) fn name_family(name: &str) -> String { + let mut stem = match name.rsplit_once('.') { + Some((s, _)) if !s.is_empty() => s, + _ => name, + }; + while let Some((head, tail)) = stem.rsplit_once('-') + && !head.is_empty() + && !tail.is_empty() + && tail.bytes().all(|b| b.is_ascii_digit()) + { + stem = head; + } + stem.to_lowercase() +} + +/// Threads for `jobs` file reads (probes, copies) at once. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn workers(jobs: usize) -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) +} + +/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]): +/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few +/// enough that each batch soon joins the catalog. +pub fn batch_size() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + workers(usize::MAX).saturating_mul(2).clamp(8, 16) + } + #[cfg(target_arch = "wasm32")] + { + 8 + } +} + +/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one +/// not started because `cancel` was set. `done` counts each one finished. +pub(super) fn run_transfers( + transfers: Vec, + file_bytes: Option<&crate::merge::ByteReader>, + cancel: &std::sync::atomic::AtomicBool, + done: &std::sync::atomic::AtomicUsize, +) -> Vec> { + use std::sync::atomic::Ordering::Relaxed; + let one = |t: Transfer| { + if cancel.load(Relaxed) { + return None; + } + let path = t.path.clone(); + // last-resort guard: a panic is this file's failure, not the import's + let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e)); + done.fetch_add(1, Relaxed); + Some(item) + }; + #[cfg(not(target_arch = "wasm32"))] + { + let n = workers(transfers.len()); + if n > 1 { + let mut results: Vec> = std::iter::repeat_with(|| None).take(transfers.len()).collect(); + let queue = std::sync::Mutex::new(transfers.into_iter().enumerate()); + let out = std::sync::Mutex::new(&mut results); + // (the tests' injected faults hold for the workers too) + #[cfg(test)] + let fault = crate::import_move::injected_fault(); + let work = &|| { + #[cfg(test)] + crate::import_move::inject(fault); + loop { + let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); + let Some((i, t)) = next else { break }; + let r = one(t); + if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { + *slot = r; + } + } + }; + std::thread::scope(|sc| { + let handles: Vec<_> = + (0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect(); + if handles.is_empty() { + // no thread to be had: copy here, one after another + work(); + } + for h in handles { + if h.join().is_err() { + log::error!("import: a copy worker stopped unexpectedly"); + } + } + }); + return results; + } + } + transfers.into_iter().map(one).collect() +} + +/// Copy or move one planned file (and Copy as DNG); its item. +fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem { + let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t; + let mut placed = None; + let mut stored = match how { + How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) { + Ok(pl) => { + let dst = pl.dst.to_string_lossy().to_string(); + placed = Some(pl); + dst + } + Err(e) => return PreparedItem::Failed(path, e), + }, + How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) { + Ok(p) => p, + Err(e) => return PreparedItem::Failed(path, e), + }, + }; + if dng { + // decoding the raw twice and encoding it takes several times the file's size in memory + let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6); + let _permit = crate::memory::work_gate().acquire(weight); + match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) { + Ok(dng) => { + let _ = std::fs::remove_file(&stored); + stored = dng; + info.format = "DNG".into(); + } + Err(e) => log::warn!("import {path}: copy as DNG: {e}"), + } + } + PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) +} + +/// A trashed photo's file is not where the library had it (never on the web, where paths are +/// storage keys, not files). +fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool { + !cfg!(target_arch = "wasm32") + && match cat.photo(id).map(|p| &p.source) { + Some(Source::File { path }) => !Path::new(path).exists(), + _ => false, + } +} + +/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; +/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are +/// taken back. +pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result { + let now = now.to_string(); + let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() }; + let mut ops = Vec::new(); + // Move: placed at the destination, sources untouched until the records are committed + let mut placed: Vec = Vec::new(); + // photos added by this batch, by content (a duplicate of a file earlier in the import names it) + let mut new_hash: HashMap = HashMap::new(); + // Fresh: trashed photos a new import replaces (by content or by file), each removed once + let (mut trash_hash, mut trash_path): (HashMap, HashMap) = Default::default(); + if opts.on_deleted == OnDeleted::Fresh { + for p in s.catalog.photos().filter(|p| p.deleted) { + if let Some(h) = &p.content_hash { + trash_hash.insert(h.clone(), p.id); + } + if let Source::File { path } = &p.source { + trash_path.insert(path.clone(), p.id); + } + } + } + let mut purge: Vec = Vec::new(); + let mut purged: std::collections::HashSet = Default::default(); + for it in prepared.items { + match it { + PreparedItem::Promote(id) => { + if s.catalog.photo(id).is_some_and(|p| p.local) { + ops.push(Op::SetLocal { id, local: false }); + } + report.imported.push(id.0); + } + PreparedItem::Duplicate { path, existing, reason, hash } => { + let existing = existing.or_else(|| { + let h = hash?; + new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id)) + }); + let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); + match trashed { + // (browsing a folder in Local only looks: it never restores) + Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => { + if !report.restored.contains(&id.0) { + ops.push(Op::SetDeleted { id, deleted: false }); + // matched by content from another path, and the file the record + // points at is gone (moved, renamed): the photo follows the file + if reason == "content" && trashed_file_is_gone(&s.catalog, id) { + ops.push(crate::cmd::missing::relink_op(id, &path)); + } + report.restored.push(id.0); + } + } + _ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }), + } + } + PreparedItem::Failed(path, e) => report.failed.push((path, e)), + PreparedItem::Kept(k) => report.kept.push(k), + PreparedItem::Ready(r) => { + let ReadyFile { path, stored, info, sidecar, placed: pl } = *r; + placed.extend(pl); + let id = s.catalog.alloc_photo_id(); + if let Some(h) = &info.content_hash { + new_hash.insert(h.clone(), id); + } + for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) { + if purged.insert(*old) { + purge.push(*old); + } + } + let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default()); + // a copy is catalogued under its new name + let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); + let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now); + p.kind = info.kind; + p.file_size = info.file_size; + p.captured = info.captured; + p.meta = info.meta; + p.as_shot_wb = info.as_shot_wb; + p.content_hash = info.content_hash; + p.embedded_lens = info.embedded_lens; + p.set_preview_only(info.preview_only.clone()); + apply_import_defaults(s, &mut p); + if let Some(sc) = &sidecar { + crate::sidecar::merge_into(&mut p, sc, &now); + report.sidecars += 1; + report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| { + warning.path = path.clone(); + warning + })); + } + if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { + crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); + } + for k in &opts.keywords { + let k = lightcraft_catalog::keywords::clean(k); + if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) { + p.meta.keywords.push(k); + } + } + if let Some(preset) = &opts.preset { + let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0)); + let label = format!("Preset: {}", preset.name); + p.develop = d.clone(); + p.edited = Some(now.clone()); + p.history.push(lightcraft_catalog::HistoryStep { label, settings: d }); + } + report.imported.push(id.0); + p.local = opts.local; + if opts.local { + // the state as browsed: a later change keeps the record from being forgotten + p.set_local_baseline(); + } + ops.push(Op::AddPhoto { photo: Box::new(p) }); + } + } + } + // (built against the catalog as it is: one op list for all of them, ahead of the additions) + if !purge.is_empty() { + ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge)); + } + let log0 = s.pending_log.len(); + let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() { + ("Restore", report.restored.len()) + } else { + ("Add", report.imported.len().max(1)) + }; + if !ops.is_empty() + && let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops }) + { + // nothing was catalogued: take the moves back (their sources were never touched) + placed.iter().for_each(crate::import_move::rollback); + return Err(e); + } + if !placed.is_empty() { + finish_moves(s, placed, log0, &mut report); + } + Ok(report) +} + +/// Move, after the commit: save the library, then remove each source whose destination is +/// still intact. Nothing is removed when the library can't be saved. +fn finish_moves(s: &mut Session, placed: Vec, log0: usize, report: &mut ImportReport) { + // (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here) + let auto: Vec = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() }; + let before = s.pending_log.len(); + if let Err(e) = s.persist() { + for p in placed { + let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display()); + report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason }); + } + return; + } + for p in placed { + let from = p.src.to_string_lossy().to_string(); + match crate::import_move::finish(&p) { + Ok(kept) => { + let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect(); + report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars }); + report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason })); + } + Err(reason) => { + log::warn!("import: move {from}: {reason}"); + report.kept.push(Kept { path: from, reason }); + } + } + } + if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() { + s.auto_write_sidecars(&auto); + } +} + +/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock +/// offset); for [`Session::clock`] on native hosts. +pub fn system_clock() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + civil(secs) + } + #[cfg(target_arch = "wasm32")] + { + "2026-01-01T00:00:00".to_string() + } +} + +/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian. +pub fn civil(secs: i64) -> String { + lightcraft_catalog::dates::civil(secs) +} diff --git a/crates/engine/src/import/defaults.rs b/crates/engine/src/import/defaults.rs new file mode 100644 index 000000000..7ca5cecc4 --- /dev/null +++ b/crates/engine/src/import/defaults.rs @@ -0,0 +1,99 @@ +use super::*; + +/// 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 +/// camera intended); a user default preset ([`ImportDefaults`]) goes on top. +pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings { + p.import_defaults() +} + +/// Does the photo still look as the camera rendered it (its embedded camera preview is then a +/// fair stand-in)? Not when a default preset changed it on import. +pub fn has_import_look(p: &Photo) -> bool { + *p.develop == p.camera_defaults() +} + +/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`). +/// Presets are referenced by id; a preset that no longer exists is ignored. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImportDefaults { + /// Preset applied to raw files (`None` = the LightCraft default). + pub raw_preset: Option, + /// Use a camera's own default (below) when the photo's camera has one. + pub per_camera: bool, + /// Per-camera raw defaults, keyed by the camera's make + model as shown in Info + /// (`"Canon EOS R5"`). + pub cameras: Vec, + /// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none). + pub other_preset: Option, + /// Copyright notice given to imported photos that don't carry one (empty = none). + pub copyright: String, + /// Creator given to imported photos that don't name one (empty = none). + pub creator: String, + /// Metadata preset applied to every imported photo (`metadata.*`; `None` = none). + pub metadata_preset: Option, + /// Auto import: a watched folder whose new photos are added as they arrive (`None` = off), + /// copied into the library's Originals instead of added in place when `auto_copy`, and an + /// album they go to. + pub auto_folder: Option, + pub auto_copy: bool, + pub auto_album: Option, + /// Open the import review when a camera or memory card is connected. + pub show_on_card: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CameraDefault { + /// Make + model (`Meta::camera`). + pub camera: String, + /// Preset id; `None` = the LightCraft default for this camera. + pub preset: Option, +} + +impl ImportDefaults { + /// The preset id that applies to a photo of this kind and camera, if any. + pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> { + if !raw { + return self.other_preset.as_deref(); + } + if self.per_camera + && let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera)) + { + return c.preset.as_deref(); + } + self.raw_preset.as_deref() + } +} + +/// Give a freshly imported photo its default settings: the camera defaults, then the matching +/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look. +pub fn apply_import_defaults(s: &Session, p: &mut Photo) { + // metadata defaults fill gaps only: the file's own copyright / creator win + let d = &s.import_defaults; + if p.meta.copyright.trim().is_empty() && !d.copyright.trim().is_empty() { + p.meta.copyright = d.copyright.trim().to_string(); + } + if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() { + p.meta.creator = d.creator.trim().to_string(); + } + if let Some(mp) = d.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { + crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); + } + let base = p.camera_defaults(); + // a raw shown from its embedded JPEG gets the rendered-file default, not the raw one + let raw = p.develops_raw(); + let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); + match preset { + Some(pr) => { + let look = std::sync::Arc::new(pr.apply(&base, 1.0)); + p.develop = look.clone(); + p.import_look = (*look != base).then_some(look); + } + None => { + p.develop = std::sync::Arc::new(base); + p.import_look = None; + } + } +} diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs new file mode 100644 index 000000000..2cbfc85e7 --- /dev/null +++ b/crates/engine/src/import/files.rs @@ -0,0 +1,372 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::commit::workers; +#[cfg(test)] +use super::job::{ImportJob, Prepared, PreparedItem, ReadyFile}; +use super::scan::ScanProgress; +#[cfg(test)] +use super::types::{ImportMode, ImportOptions}; +use super::*; + +/// 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. +pub const EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", + "srw", "psd", "psb", "jxl", "gif", "bmp", + // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as + // failed with the reason ("HEIC/HEIF support isn't included in this build"). + "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", +]; + +pub fn is_supported(path: &Path) -> bool { + path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) +} + +/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension +/// is listed in both cases: browsers' `accept` matching is case-insensitive per +/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and +/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws +/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker. +/// +/// The picker only offers files this build can actually import: `.avif` has no +/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs' +/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]). +/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised, +/// and import reports them as failed, saying why. +pub fn accept_list() -> String { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + let mut out = String::new(); + for &ext in EXTENSIONS { + // Skip extensions a web user could pick but this build could not import. + let pickable = match ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }; + if !pickable { + continue; + } + if !out.is_empty() { + out.push(','); + } + out.push('.'); + out.push_str(ext); + out.push(','); + out.push('.'); + out.push_str(&ext.to_uppercase()); + } + out +} + +/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) +/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a +/// walk that stops at a bound is logged. +pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { + expand_within(paths, skip, crate::walk::Limits::default()).0 +} + +/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). +pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec, bool) { + expand_noting(paths, skip, limits, None) +} + +/// [`expand_within`], handing the listing of every folder that is walked to `note` +/// ([`crate::walk::files_in_noting`]). +pub(crate) fn expand_noting( + paths: &[String], + skip: Option<&Path>, + limits: crate::walk::Limits, + mut note: Option, +) -> (Vec, bool) { + let mut out = Vec::new(); + let mut truncated = false; + for p in paths { + let p = Path::new(p); + if skip.is_some_and(|s| p == s) { + continue; + } + if p.is_dir() { + let w = match note.as_mut() { + Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note), + None => crate::walk::files_in(p, skip, limits, is_supported), + }; + if w.truncated { + log::warn!( + "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", + p.display(), + w.entries, + limits.max_entries, + limits.max_depth + ); + } + truncated |= w.truncated; + out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string())); + } else { + // explicitly named files are attempted even with an unknown extension (sniffed) + out.push(p.to_string_lossy().to_string()); + } + } + let mut seen = std::collections::HashSet::new(); + out.retain(|p| seen.insert(p.clone())); + (out, truncated) +} + +/// What a path given on the command line at launch becomes ([`launch_path`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LaunchPath { + /// Import this (absolute) file or folder, in the background. + Import(String), + /// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never + /// walked without being chosen in the app (issue #374). + Refused { path: String, why: String }, +} + +/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`); +/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is +/// resolved against `cwd` — the working folder a launcher picked, often a drive root or +/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive +/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or +/// refused, and so is the home folder `home`: either would walk most of the disk. +pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option) -> Option { + if arg.trim().is_empty() { + return None; + } + let given = Path::new(arg); + let joined = if given.is_absolute() { + given.to_path_buf() + } else { + match cwd { + Some(c) => c.join(given), + None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }), + } + }; + // `.` and `..` resolved by name (no file-system access) + let mut abs = PathBuf::new(); + for c in joined.components() { + match c { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + abs.pop(); + } + other => abs.push(other.as_os_str()), + } + } + let shown = abs.to_string_lossy().to_string(); + if abs.parent().is_none() { + return Some(match dcim(&abs) { + Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()), + None => LaunchPath::Refused { path: shown, why: "a whole drive".into() }, + }); + } + let key = lightcraft_catalog::query::folder_key; + if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) { + return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() }); + } + Some(LaunchPath::Import(shown)) +} + +/// Probe files (headers + content hash) as an import would. +pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { + let progress = ScanProgress::default(); + probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) +} + +/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread. +pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec> { + let progress = ScanProgress::default(); + probe_all(Some(probe), paths, &progress.done, &progress.cancel) +} + +/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once +/// `cancel` is set: the files not reached come back as errors. +pub(crate) fn probe_all( + probe: Option<&crate::media::FileProbe>, + paths: &[String], + done: &std::sync::atomic::AtomicUsize, + cancel: &std::sync::atomic::AtomicBool, +) -> Vec> { + use std::sync::atomic::Ordering::Relaxed; + let Some(probe) = probe.cloned() else { + return paths + .iter() + .map(|p| { + Ok(ProbeInfo { + format: Path::new(p).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(), + ..Default::default() + }) + }) + .collect(); + }; + #[cfg(not(target_arch = "wasm32"))] + { + let n = workers(paths.len()); + if n > 1 { + let mut results: Vec>> = vec![None; paths.len()]; + let next = std::sync::atomic::AtomicUsize::new(0); + let out = std::sync::Mutex::new(&mut results); + std::thread::scope(|sc| { + for _ in 0..n { + sc.spawn(|| { + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if i >= paths.len() { + break; + } + if cancel.load(Relaxed) { + break; + } + let r = probe(&paths[i]); + done.fetch_add(1, Relaxed); + out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); + } + }); + } + }); + return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect(); + } + } + paths + .iter() + .map(|p| { + if cancel.load(Relaxed) { + return Err("cancelled".into()); + } + let r = probe(p); + done.fetch_add(1, Relaxed); + r + }) + .collect() +} + +/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the +/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and +/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte +/// with the source when there is none); a bad one is removed and the photo reported as failed. +pub(super) fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result { + let name = name + .map(str::to_string) + .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); + let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse); + crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod prepared_tests { + use super::*; + + /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the + /// files this build cannot import (see [`accept_list`]). + fn pickable_extensions() -> Vec<&'static str> { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + EXTENSIONS + .iter() + .copied() + .filter(|ext| match *ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }) + .collect() + } + + #[test] + fn accept_list_covers_every_pickable_extension_in_both_cases() { + let list = accept_list(); + for ext in pickable_extensions() { + assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list"); + assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase()); + } + } + + /// The web picker must not offer files this build cannot import: `.avif` has + /// no AV1 decoder in any build, so it is never listed, in either case. + #[test] + fn accept_list_never_offers_avif() { + let list = accept_list(); + assert!(!list.contains(".avif"), "{list}"); + assert!(!list.contains(".AVIF"), "{list}"); + } + + /// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases) + /// only when this build can decode them; otherwise hidden, so a web user + /// cannot pick a file that would fail to import. + #[test] + fn accept_list_offers_heic_heif_only_when_decodable() { + let list = accept_list(); + let listed = lightcraft_codecs::Format::Heif.can_decode(); + for ext in ["heic", "heif"] { + assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}"); + assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}"); + } + } + + #[test] + fn revalidate_add_only_converts_matching_ready_item() { + let mut s = Session::new(); + let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); + let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); + let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); + s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); + let ready = |path: &Path| ReadyFile { + path: path.to_string_lossy().into(), + stored: path.to_string_lossy().into(), + info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, + sidecar: None, + placed: None, + }; + let mut prepared = Prepared { + scanned: 2, + items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], + }; + prepared.revalidate_add(&s); + assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); + assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); + } + + fn unread_paths(n: usize) -> Vec { + (0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect() + } + + /// Reading the files of a large import (a Lightroom catalog's originals) reported nothing: + /// the progress stood still at "14 of 24554" for as long as it took. + #[test] + fn prepare_counts_the_files_it_reads() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (done, last) = (job.read_done.clone(), done_in_probe.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + last.fetch_max(done.load(Relaxed), Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false)); + assert_eq!(prepared.items.len(), 40); + assert_eq!(seen.load(Relaxed), 40); + assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read"); + assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted"); + assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own"); + } + + /// Cancel had no effect until every file had been read. + #[test] + fn cancel_stops_reading_files() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + cancel_in_probe.store(true, Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(400), &cancel); + // at most the files the (up to 8) readers had already started + assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); + assert!(prepared.items.is_empty()); + } +} diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs new file mode 100644 index 000000000..47bc0756f --- /dev/null +++ b/crates/engine/src/import/job.rs @@ -0,0 +1,486 @@ +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::*; + +/// Import files/folders. See the module docs. +pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { + import_with(s, paths, &ImportOptions { mode, ..Default::default() }) +} + +/// Import files/folders with options (preset, keywords). See the module docs. +/// +/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars, +/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can +/// run the first on a worker thread; here both run in turn. +pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result { + let mut job = ImportJob::new(s, opts.clone())?; + let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false)); + // probes a scan left for files not imported now stay for a later import + s.import_probes.extend(std::mem::take(&mut job.cache)); + commit_prepared(s, &job.opts, &job.now, prepared) +} + +/// Everything the file-system half of an import needs, detached from the session so it can run +/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`]; +/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting, +/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session. +pub struct ImportJob { + pub opts: ImportOptions, + /// The mode in effect (Copy / Move become Add in a library without its own folder). + mode: ImportMode, + lib_dir: Option, + copy_root: Option, + /// The library as it was when the import was set up, until it is looked up + /// ([`ImportJob::look_up_library`], with the first files): setting an import up is the + /// session's thread, and a table of a library of millions of photos takes seconds. With it, + /// whether a photo in Recently Deleted is passed over (an import that replaces such photos, + /// as the options said when the import was set up). + pub(super) library: Option<(lightcraft_catalog::Catalog, bool)>, + /// path → (photo, a Local browse record?) + pub(super) by_path: HashMap, + /// content hash → the photo that has it (`None`: a file earlier in this import) + pub(super) by_hash: HashMap>, + pub(super) probe: Option, + file_bytes: Option, + /// Probes from a preceding scan (not read again). + cache: HashMap, + naming: crate::sidecar::SidecarNaming, + now: String, + seq: usize, + /// Files found so far (folders expanded) and handled so far, for progress. + pub total: std::sync::Arc, + pub done: std::sync::Arc, + /// Of those, the files that have to be read first (not probed by a preceding scan) and the + /// ones read so far: the step before they are handled. + pub read_total: std::sync::Arc, + pub read_done: std::sync::Arc, +} + +/// The outcome of [`ImportJob::prepare`] for one batch, in file order. +#[derive(Debug, Default)] +pub struct Prepared { + pub scanned: usize, + pub(super) items: Vec, +} + +#[derive(Debug)] +pub(super) enum PreparedItem { + /// A Local (browsed) record imported for real. + Promote(PhotoId), + Duplicate { + path: String, + existing: Option, + reason: &'static str, + hash: Option, + }, + Failed(String, String), + Kept(Kept), + Ready(Box), +} + +#[derive(Debug)] +pub(super) struct ReadyFile { + pub(super) path: String, + pub(super) stored: String, + pub(super) info: ProbeInfo, + pub(super) sidecar: Option, + pub(super) placed: Option, +} + +impl Prepared { + /// Undo what the batch did on disk that only a commit would make permanent: files placed by a + /// Move (their sources were never touched). Copies stay, like copies of a failed commit. + pub fn rollback(&self) { + for it in &self.items { + if let PreparedItem::Ready(r) = it + && let Some(pl) = &r.placed + { + crate::import_move::rollback(pl); + } + } + } + + /// Entries in the batch (after expanding folders). + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Recheck Add-only entries against the owner catalog immediately before commit. + /// Preparation may have run while another import added the same path or content. + pub fn revalidate_add(&mut self, s: &Session) { + let mut paths = HashMap::new(); + let mut hashes = HashMap::new(); + for photo in s.catalog.photos() { + if let Source::File { path } = &photo.source { + paths.insert(normalize_import_path(path), photo.id); + } + if let Some(hash) = &photo.content_hash { + hashes.insert(hash.clone(), photo.id); + } + } + let mut items = Vec::with_capacity(self.items.len()); + for item in std::mem::take(&mut self.items) { + let PreparedItem::Ready(ready) = item else { + items.push(item); + continue; + }; + let ReadyFile { path, stored, info, sidecar, placed } = *ready; + let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied()); + let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied()); + if let Some(existing) = existing_path.or(existing_hash) { + if let Some(placed) = &placed { + crate::import_move::rollback(placed); + } + items.push(PreparedItem::Duplicate { + path, + existing: Some(existing), + reason: if existing_path.is_some() { "path" } else { "content" }, + hash: info.content_hash, + }); + } else { + items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); + } + } + self.items = items; + } +} + +fn normalize_import_path(path: &str) -> String { + let path = path.replace('\\', "/"); + if cfg!(windows) { path.to_lowercase() } else { path } +} + +impl ImportJob { + /// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes + /// the probes a preceding scan left ([`Session::import_probes`]). + pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result { + let mode = opts.mode; + // Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of + // every added file in its own storage already, so "copy" there means "add". + let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move); + let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode }; + if mode == ImportMode::Move && opts.local { + return Err(crate::EngineError::Other("browsing a folder never moves its files".into())); + } + let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + // where copies go: the chosen folder, else the library's Originals/ + let copy_root = opts + .destination + .as_deref() + .filter(|d| !d.trim().is_empty()) + .map(std::path::PathBuf::from) + .or_else(|| lib_dir.as_ref().map(|l| l.join("Originals"))); + if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() { + return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into())); + } + let fresh = opts.on_deleted == OnDeleted::Fresh; + Ok(ImportJob { + seq: opts.rename_start.max(1), + opts, + mode, + lib_dir, + copy_root, + library: Some((s.catalog.clone(), fresh)), + by_path: HashMap::new(), + by_hash: HashMap::new(), + probe: s.media.file_probe.clone(), + file_bytes: s.media.file_bytes.clone(), + cache: std::mem::take(&mut s.import_probes), + naming: s.xmp.naming, + now: (s.clock)(), + total: Default::default(), + done: Default::default(), + read_total: Default::default(), + read_done: Default::default(), + }) + } + + /// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip + /// duplicates by content, read sidecars, copy or place (Move) the files — several at once, + /// with the names and outcomes of one after another. Nothing in the catalog changes. Stops + /// before the next file once `cancel` is set. + pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared { + let files = self.expand(paths); + self.prepare_files(files, cancel) + } + + /// Expand `paths` (folders recursively, the library's own folder skipped) into the files an + /// import would handle; counted in [`ImportJob::total`]. + pub fn expand(&self, paths: &[String]) -> Vec { + let files = expand(paths, self.lib_dir.as_deref()); + self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed); + files + } + + /// When the import started ([`Session::clock`]), for [`commit_prepared`]. + pub fn now(&self) -> &str { + &self.now + } + + /// What the library held when the import was set up, by path and by content: made once, with + /// the first files (on the thread that prepares them), from the library as it was then. The + /// snapshot comes back: the files the import is adding are ranked against it for the ownership + /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). + fn look_up_library(&mut self) -> Option { + let (library, fresh) = self.library.take()?; + for p in library.photos() { + // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate + if p.deleted && fresh { + continue; + } + if let Source::File { path } = &p.source { + self.by_path.insert(path.clone(), (p.id, p.local)); + } + if let Some(h) = &p.content_hash { + self.by_hash.insert(h.clone(), Some(p.id)); + } + } + Some(library) + } + + /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. + pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { + use std::sync::atomic::Ordering::Relaxed; + // the library as it was, kept for the ownership of the shared stem sidecars below + let library = self.look_up_library(); + let opts = self.opts.clone(); + let mode = self.mode; + let mut out = Prepared { scanned: files.len(), items: Vec::new() }; + let mut todo = Vec::new(); + for f in files { + match self.by_path.get(f.as_str()).copied() { + Some((id, true)) if !opts.local => { + // a file that was only browsed (Local) joins the library when it is imported for real + self.by_path.insert(f, (id, false)); + out.items.push(PreparedItem::Promote(id)); + self.done.fetch_add(1, Relaxed); + } + Some((id, _)) => { + out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None }); + self.done.fetch_add(1, Relaxed); + } + None => todo.push(f), + } + } + + // files a preceding scan already probed are not read again (a network share is slow to read), + // as long as they are still there, as they were: a file gone or changed since is probed again + let cached: Vec> = todo + .iter() + .map(|f| { + // (the web's files are not std::fs files: kept as probed) + self.cache + .remove(f) + .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) + }) + .collect(); + let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); + // Reading the files is the slow part of adding them where they are: it is counted on its + // own, and `cancel` stops it too (a large library takes long enough to want both). + self.read_total.fetch_add(missing.len(), Relaxed); + let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter(); + let probed: Vec> = + cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); + crate::memory::release(); + // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones + // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar + // and never the raw's packet (issue #1905). + let stems = crate::sidecar::ImportStems::for_import( + library.as_ref(), + todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), + ); + // Files are planned one by one, in order (duplicates, sidecars, names, folders); their + // copies or moves — the slow part, reading and writing every byte and verifying it — are + // collected and run on several threads (issue #367), then their outcomes take their places. + let mut plan = Plan::default(); + for (path, info) in todo.into_iter().zip(probed) { + if cancel.load(Relaxed) { + break; + } + let mut info = match info { + Ok(i) => i, + Err(e) => { + log::warn!("import {path}: {e}"); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Failed(path, e))); + continue; + } + }; + // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds + if !opts.local + && let Some(h) = &info.content_hash + && plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h)) + { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; + } + } + if let Some(h) = &info.content_hash + && !opts.local + && let Some(existing) = self.by_hash.get(h) + { + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) })); + continue; + } + // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files + // and names the copy when the file itself has none + let raw = info.kind == MediaKind::Raw; + let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) + .and_then(|f| std::fs::read_to_string(f).ok()) + .or_else(|| info.xmp.clone().filter(|_| raw)); + let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); + let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { + Ok(sc) => Some(sc), + Err(e) => { + log::warn!("import {path}: XMP: {e}"); + None + } + }); + if info.captured.is_none() { + info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); + } + // no date in the file or its sidecar: the file's own date, as Lightroom does + if info.captured.is_none() { + info.captured = info.file_modified.clone(); + } + let stored = match (mode, &self.copy_root) { + (ImportMode::Copy | ImportMode::Move, Some(root)) + if !crate::import_move::inside(Path::new(&path), root) + && !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) => + { + // the templates see the photo as it will be catalogued (undated: the import time) + let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now); + q.captured = info.captured.clone(); + q.meta = info.meta.clone(); + let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| { + let n = crate::rename::expand(t, &q, self.seq); + self.seq += 1; + n + }); + let folders = opts.organize.folders(&q); + let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); + // the name this file gets depends on what a pending transfer of a like-named file + // into the same folder ends up with: that one goes first + let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() }); + let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named)); + if plan.transfers.iter().any(|t| t.family == family) { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; + } + } + let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { + How::Place { name: name.unwrap_or(own) } + } else { + if mode == ImportMode::Move { + let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); + } + How::Copy { name } + }; + // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) + let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG"); + plan.slots.push(Slot::Transfer); + plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family }); + continue; + } + (ImportMode::Move, _) => { + let reason = "already inside the destination or the library: added where it is"; + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); + path.clone() + } + _ => path.clone(), + }; + if let Some(h) = &info.content_hash { + self.by_hash.insert(h.clone(), None); + } + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None })))); + } + self.run_plan(&mut plan, &mut out, cancel); + out + } + + /// Run the transfers `plan` collected (on several threads) and append its items to `out` in + /// file order. Transfers not started because `cancel` was set leave no item. + fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) { + let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter(); + for slot in std::mem::take(&mut plan.slots) { + let item = match slot { + Slot::Item(it) => it, + Slot::Transfer => match done.next().flatten() { + Some(it) => it, + None => continue, + }, + }; + if let PreparedItem::Ready(r) = &item + && let Some(h) = &r.info.content_hash + { + self.by_hash.insert(h.clone(), None); + } + out.items.push(item); + } + } +} + +impl Session { + /// A thumbnail for an import candidate (not in the catalog): a raw's embedded preview, else a + /// small render of the file. `slot` makes the job's photo id unique (`u64::MAX - slot`). + pub fn candidate_thumb_job(&mut self, c: &ImportCandidate, edge: usize, slot: u64) -> Option { + if c.error.is_some() { + return None; + } + let id = PhotoId(u64::MAX - slot); + let mut p = Photo::new(id, Source::File { path: c.path.clone() }, &c.name, &c.format, c.width.max(1), c.height.max(1), ""); + p.kind = c.kind; + if let Some(info) = self.import_probes.get(&c.path) { + p.as_shot_wb = info.as_shot_wb; + p.embedded_lens = info.embedded_lens; + p.preview_only = info.preview_only.clone(); + } + p.develop = std::sync::Arc::new(p.import_defaults()); + let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge()); + let level = crate::media::SourceLevel::Thumb; + let source = self.media.origin_ref(&p.source, level.max_edge()); + let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64; + let small = crate::media::RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: None, + photo: id, + level, + source, + origin: p.source.clone(), + info: crate::media::source_info(&p), + settings: p.develop.clone(), + request: lightcraft_pipeline::RenderRequest::fit(edge, edge), + key, + cache: None, + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut: None, + display_fit: None, + }; + let embedded = match (&self.media.preview_loader, c.kind) { + (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), + _ => None, + }; + Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) }) + } + + /// Use the system clock for import/edit times (native hosts). + pub fn with_system_clock(mut self) -> Self { + self.clock = Box::new(system_clock); + self + } +} diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs new file mode 100644 index 000000000..61d8d4690 --- /dev/null +++ b/crates/engine/src/import/scan.rs @@ -0,0 +1,188 @@ +use super::files::{expand, probe_all}; +use super::types::ImportCandidate; +use super::*; + +/// 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). +pub struct ScanInput { + probe: Option, + skip: Option, + /// The library as it is now (photos are shared with the session, not copied): looked up for + /// the files the scan finds ([`library_files`]) and the contents it probes + /// ([`library_hashes`]), on the scan's thread. + library: std::sync::Arc, + cache: HashMap, +} + +/// The library's photos of `files`: path → (photo, its summary as a candidate that is there +/// already). One pass over the library, and a record only for a file that was found: a folder of +/// a hundred files in a library of millions costs a hundred records. +pub(super) fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap { + let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect(); + let mut by_path = HashMap::new(); + if found.is_empty() { + return by_path; + } + for p in library.photos() { + if let Source::File { path } = &p.source + && found.contains(Path::new(path)) + { + let c = ImportCandidate { + path: path.clone(), + format: p.format.clone(), + kind: p.kind, + width: p.width, + height: p.height, + file_size: p.file_size, + captured: p.captured.clone(), + duplicate: Some("path".into()), + existing: Some(p.id.0), + ..Default::default() + }; + by_path.insert(PathBuf::from(path), (p.id, c)); + } + } + by_path +} + +/// Which photo of the library has each of the contents `hashes` (the later one where several +/// have it). One pass over the library, and an entry only for a content that was asked about. +pub(super) fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator) -> HashMap { + let wanted: std::collections::HashSet<&str> = hashes.collect(); + let mut by_hash = HashMap::new(); + if wanted.is_empty() { + return by_hash; + } + for p in library.photos() { + if let Some(h) = &p.content_hash + && wanted.contains(h.as_str()) + { + by_hash.insert(h.clone(), p.id); + } + } + by_hash +} + +/// Progress and cancellation of a running [`scan_with`]. +#[derive(Default)] +pub struct ScanProgress { + /// Files to probe, set once the folders are expanded. + pub total: std::sync::atomic::AtomicUsize, + pub done: std::sync::atomic::AtomicUsize, + /// Shared with the scan's row in the activity stack (its ✕ sets it). + pub cancel: std::sync::Arc, +} + +/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows. +pub struct ScanOutput { + pub candidates: Vec, + pub probes: HashMap, +} + +impl ScanInput { + pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec) { + let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + // (no table of the library here: this is the session's thread, and the library may hold + // millions of photos. The scan looks up what it finds) + let library = std::sync::Arc::new(s.catalog.clone()); + let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) }; + (input, paths.to_vec()) + } + + /// The folder a scan never enters (the library's own). + pub(crate) fn skip(&self) -> Option<&Path> { + self.skip.as_deref() + } + + /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one). + pub(crate) fn library(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.library) + } +} + +/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content +/// against the library and earlier candidates). Nothing is added; the probes are kept for the +/// import that follows. +pub fn scan(s: &mut Session, paths: &[String]) -> Vec { + let (input, paths) = ScanInput::new(s, paths); + let out = scan_with(input, &paths, &ScanProgress::default()); + s.import_probes = out.probes; + out.candidates +} + +/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it +/// has) when `progress.cancel` is set. +pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { + let files = expand(paths, input.skip.as_deref()); + scan_files(input, files, progress) +} + +/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]). +pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &ScanProgress) -> ScanOutput { + use std::sync::atomic::Ordering::Relaxed; + let by_path = library_files(&input.library, &files); + let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); + progress.total.store(todo.len(), Relaxed); + // probes from a preceding `scan` are reused when the file is unchanged (same size) + let cached: Vec> = todo + .iter() + .map(|f| { + let info = input.cache.remove(f)?; + // (✕ set while the files' cached probes are checked: no more round trips to the disk; what is + // not reached counts as not probed, like the files after it) + if progress.cancel.load(Relaxed) { + return None; + } + let size = std::fs::metadata(f).map(|m| m.len()).ok(); + (size.is_none() || size == Some(info.file_size)).then_some(info) + }) + .collect(); + let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); + progress.done.store(todo.len() - missing.len(), Relaxed); + let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); + let probed: Vec> = + cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); + crate::memory::release(); + let mut probes: HashMap> = todo.into_iter().zip(probed).collect(); + // (who in the library has a content that was probed: asked once the probes are in) + let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref()); + let mut seen_hash: HashMap> = + library_hashes(&input.library, probed_hashes).into_iter().map(|(h, id)| (h, Some(id.0))).collect(); + let mut out = Vec::with_capacity(files.len()); + let mut kept = HashMap::new(); + for f in files { + if let Some((_, c)) = by_path.get(Path::new(&f)) { + let mut c = c.clone(); + c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); + out.push(c); + continue; + } + let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); + let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() }; + match probes.remove(&f) { + Some(Ok(info)) => { + (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = + (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); + // as the import will date it (a sidecar's date, when there is one, still wins there) + c.captured = c.captured.or_else(|| info.file_modified.clone()); + c.preview_only = info.preview_only.clone(); + if let Some(h) = &info.content_hash { + match seen_hash.get(h) { + Some(existing) => { + c.duplicate = Some("content".into()); + c.existing = *existing; + } + None => { + seen_hash.insert(h.clone(), None); + } + } + } + kept.insert(f, info); + } + Some(Err(e)) => c.error = Some(e), + None => c.error = Some("not probed".into()), + } + out.push(c); + } + ScanOutput { candidates: out, probes: kept } +} diff --git a/crates/engine/src/import/types.rs b/crates/engine/src/import/types.rs new file mode 100644 index 000000000..4a848015b --- /dev/null +++ b/crates/engine/src/import/types.rs @@ -0,0 +1,230 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ImportMode { + /// Reference the files where they are. + #[default] + Add, + /// Copy the files into the library's `Originals/` folder. + Copy, + /// Move the files there: as Copy, then each original (and its sidecars) is removed from the + /// source once the copy is verified and catalogued. + Move, +} + +/// What an import does with a file the library already has in Recently Deleted. +/// +/// Someone who imports such a file wants the photo in the library again, so the default brings +/// it back (issues #298, #706: before, the import skipped it as "already in the library" while +/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits, +/// rating and albums come back with the photo and Reset Edits gives a clean start — where +/// importing afresh would silently drop them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OnDeleted { + /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). + Skip, + /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose + /// file is no longer where the library had it is relinked to the imported file. + #[default] + Restore, + /// Delete the trashed record permanently and import the file as a new photo. + Fresh, +} + +impl OnDeleted { + pub fn parse(s: &str) -> Option { + match s { + "skip" => Some(Self::Skip), + "restore" => Some(Self::Restore), + "fresh" => Some(Self::Fresh), + _ => None, + } + } +} + +/// What an import does besides adding the photos. +#[derive(Clone, Debug, Default)] +pub struct ImportOptions { + /// A file that is in Recently Deleted: restore (default), skip, or import afresh. + pub on_deleted: OnDeleted, + pub mode: ImportMode, + /// Applied to every imported photo (one History entry). + pub preset: Option, + /// Added to every imported photo. + pub keywords: Vec, + /// Browsing a folder: photos come in as `local` (not in the library), and a file with the + /// same content as one already known is still listed. + pub local: bool, + /// Copy: where the copies go (default: the library's `Originals/`). + pub destination: Option, + /// Copy: the folders inside the destination. + pub organize: Organize, + /// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`, + /// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`. + pub rename: Option, + pub rename_start: usize, + /// A metadata preset (by name) applied to every imported photo. + pub metadata_preset: Option, + /// Copy: raws are copied as DNG (Copy as DNG). + pub convert_dng: bool, +} + +/// How copies are filed in the destination. The date is the capture time, else the time of the +/// import. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Organize { + /// `YYYY/YYYY-MM-DD/` by capture date. + #[default] + ByDay, + /// `YYYY/YYYY-MM/`. + ByMonth, + /// All in the destination itself. + Flat, + /// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own + /// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`]; + /// never outside the destination). + Template(String), +} + +impl Organize { + /// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`). + pub fn parse(s: &str) -> Option { + match s { + "date" | "day" | "byDay" => Some(Organize::ByDay), + "month" | "byMonth" => Some(Organize::ByMonth), + "flat" | "none" | "intoOneFolder" => Some(Organize::Flat), + t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())), + _ => None, + } + } + + /// The folders (inside the destination) a copy of `p` goes to. + pub fn folders(&self, p: &Photo) -> Vec { + let date = p.date(); + let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); + let year = day.get(..4).unwrap_or("undated"); + match self { + Organize::ByDay => vec![year.into(), day.into()], + Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()], + Organize::Flat => Vec::new(), + Organize::Template(t) => crate::rename::expand_folder(t, p, 1), + } + } +} + +/// A file found by [`scan`], for the import review. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImportCandidate { + pub path: String, + pub name: String, + pub format: String, + pub kind: MediaKind, + pub width: u32, + pub height: u32, + pub file_size: u64, + pub captured: Option, + /// `"path"` (already in the library), `"content"` (same bytes in the library or earlier in this + /// list): skipped by the import. + pub duplicate: Option, + /// The photo that already has it. + pub existing: Option, + /// Not readable. + pub error: Option, + /// A raw variant that can't be decoded yet (why): it imports as preview only. + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_only: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Duplicate { + pub path: String, + /// The photo that already has these bytes (or this path). + pub existing: Option, + /// `"path"` (already imported from there) or `"content"` (same bytes elsewhere). + pub reason: &'static str, + /// That photo is in Recently Deleted (see [`OnDeleted`]). + #[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")] + pub existing_deleted: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct ImportReport { + pub imported: Vec, + pub duplicates: Vec, + /// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub restored: Vec, + /// Move: the originals that were moved (and removed from the source). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub moved: Vec, + /// Move: originals (or sidecars) left at the source, and why — the photo may still have been + /// imported (from its copy, or in place). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub kept: Vec, + /// (path, error) + pub failed: Vec<(String, String)>, + /// Files found after expanding folders. + pub scanned: usize, + /// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP). + pub sidecars: usize, + /// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// A structured diagnostic from foreign XMP develop settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImportWarningCode { + /// A named camera profile was found but cannot be applied as Lightroom does. + CameraProfile, + /// Lightroom geometry data was found but its saved transform is not retained. + UprightGeometry, + /// One or more other XMP develop fields have no LightCraft mapping. + UnmappedXmp, +} + +/// The saved Lightroom Upright mode associated with a foreign geometry transform. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImportUprightMode { + Off, + Auto, + Level, + Vertical, + Full, + Guided, + Unknown, +} + +/// One actionable XMP import diagnostic. `path` is filled when its photo is committed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportWarning { + pub path: String, + pub code: ImportWarningCode, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +/// A file moved by an import. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Moved { + pub from: String, + pub to: String, + /// Its sidecars' new paths. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub sidecars: Vec, +} + +/// A source a Move left where it was. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Kept { + pub path: String, + pub reason: String, +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff2b972c0..acaa18070 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1360,90 +1360,7 @@ pub fn json_delta(old: &Value, new: &Value) -> Option { #[cfg(test)] mod tests; #[cfg(test)] -mod tests_album_order; -#[cfg(test)] -mod tests_album_remove; -#[cfg(test)] -mod tests_albums_local; -#[cfg(test)] -mod tests_color; -#[cfg(test)] -mod tests_constrain_crop; -#[cfg(test)] -mod tests_crop_angle; -#[cfg(test)] -mod tests_crop_aspect; -#[cfg(test)] -mod tests_denoise; -#[cfg(test)] -mod tests_export; -#[cfg(test)] -mod tests_face_models; -#[cfg(test)] #[cfg(not(target_arch = "wasm32"))] -mod tests_face_recognize; +use tests::face_recognize as tests_face_recognize; #[cfg(test)] -#[cfg(not(target_arch = "wasm32"))] -mod tests_face_tiles; -#[cfg(test)] -mod tests_filesystem_history; -#[cfg(all(test, not(target_arch = "wasm32")))] -mod tests_focus_scan; -#[cfg(test)] -mod tests_folders; -#[cfg(test)] -mod tests_forget_local; -#[cfg(test)] -mod tests_history_cursor; -#[cfg(test)] -mod tests_hold_view; -#[cfg(test)] -mod tests_ids_params; -#[cfg(test)] -mod tests_import; -#[cfg(test)] -mod tests_import_move; -#[cfg(test)] -mod tests_keyword_list; -#[cfg(test)] -mod tests_libops; -#[cfg(test)] -mod tests_library; -#[cfg(test)] -mod tests_lightroom_folder; -#[cfg(test)] -mod tests_mask_effects; -#[cfg(test)] -mod tests_merge; -#[cfg(test)] -mod tests_move_to_share; -#[cfg(test)] -mod tests_organize; -#[cfg(test)] -mod tests_persist; -#[cfg(test)] -mod tests_prefs; -#[cfg(test)] -mod tests_presets_scale; -#[cfg(test)] -mod tests_print; -#[cfg(test)] -mod tests_process; -#[cfg(test)] -mod tests_recheck; -#[cfg(test)] -mod tests_segment; -#[cfg(test)] -mod tests_settings_files; -#[cfg(test)] -mod tests_slide; -#[cfg(test)] -mod tests_slideshow_export; -#[cfg(test)] -mod tests_spots; -#[cfg(test)] -mod tests_sync; -#[cfg(test)] -mod tests_view_follow; -#[cfg(test)] -mod tests_xmp; +use tests::xmp as tests_xmp; diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 797a96708..207d1c148 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -1099,818 +1099,4 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_geom::CropGeometry; - fn row(v: Value) -> Row { - serde_json::from_value(v).unwrap() - } - fn sample() -> CatalogImport { - let mut photo = CatalogPhoto { - source_id: 1, - uuid: "test-master".into(), - path: "/missing/test.ARW".into(), - image: row(json!({"id_local":1,"rating":5,"pick":1,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})), - settings: "s = { Exposure2012 = 1.25, Contrast2012 = 17, CameraProfile = \"Example\" }".into(), - xmp: String::new(), - keywords: vec!["Travel|Goa".into()], - history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], - snapshots: Vec::new(), - original: Original::Local, - }; - let mut copy = photo.clone(); - copy.source_id = 2; - copy.uuid = "test-copy".into(); - copy.image = row(json!({"id_local":2,"masterImage":1,"copyName":"Mono","rating":2,"pick":-1})); - copy.settings = "s = { ConvertToGrayscale = true, Exposure2012 = -0.5 }".into(); - photo.image.insert("captureTime".into(), json!("2024-01-16T12:34:56")); - CatalogImport { - source: "test.lrcat".into(), - photos: vec![copy, photo], - collections: vec![ - row(json!({"id_local":9,"name":"Travel","creationId":"collection.group"})), - row(json!({"id_local":10,"parent":9,"name":"Keepers","creationId":"collection"})), - ], - members: vec![ - row(json!({"collection":10,"image":2,"positionInCollection":"a"})), - row(json!({"collection":10,"image":1,"positionInCollection":"b"})), - ], - collection_content: Vec::new(), - warnings: Vec::new(), - skipped: Vec::new(), - } - } - /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so - /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual - /// copies go with it; the catalog's real photos still import. - #[test] - fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { - let mut data = sample(); - let mut exe = data.photos[1].clone(); - exe.source_id = 3; - exe.uuid = "payload".into(); - exe.path = "/Users/sky/Downloads/payload.exe".into(); - exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); - let mut exe_copy = exe.clone(); - exe_copy.source_id = 4; - exe_copy.uuid = "payload-copy".into(); - exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); - exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); - // the copy comes before its master, as a catalog's table order may have it - data.photos.insert(0, exe_copy); - data.photos.push(exe); - for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { - let mut one = data.clone(); - one.photos.retain(|p| p.source_id < 3); - let mut planted = one.photos[1].clone(); - planted.source_id = 5; - planted.uuid = "planted".into(); - planted.path = bad.into(); - planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); - one.photos.push(planted); - let mut s = crate::Session::new(); - let r = apply(&mut s, one, false).unwrap(); - assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); - assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); - } - let mut s = crate::Session::new(); - let r = apply(&mut s, data, false).unwrap(); - assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); - assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); - let warnings = r["warnings"].to_string(); - assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); - assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); - } - - #[test] - fn offline_local_master_and_copy_join_library_with_one_undo_step() { - let mut s = crate::Session::new(); - let data = sample(); - let master_id = s.catalog.alloc_photo_id(); - let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); - master.local = true; - s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); - s.undo.clear(); - let before = s.catalog.photo(master_id).unwrap().clone(); - let mut index = ImportIndex::default(); - let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); - let mut import = |s: &mut crate::Session| { - let undo0 = s.undo.len(); - apply_prepared( - s, - data.clone(), - ApplyPrepared { - update_existing: false, - files: Default::default(), - index: &mut index, - archive_path: None, - missing_sources: &missing_sources, - undo0, - }, - ) - .unwrap() - .report - }; - let first = import(&mut s); - assert_eq!(first["mapping"]["1"], master_id.0); - assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); - assert_eq!(first["missing"], json!(["/missing/test.ARW"])); - assert_eq!(s.catalog.len(), 2); - assert!(s.catalog.photos().all(|photo| !photo.local)); - assert_eq!(s.undo.len(), 1); - let second = import(&mut s); - assert_eq!(second["imported"], 0); - assert_eq!(second["mapping"], first["mapping"]); - s.undo_step().unwrap(); - s.undo_step().unwrap(); - assert_eq!(s.catalog.len(), 1); - assert_eq!(s.catalog.photo(master_id), Some(&before)); - assert!(s.catalog.albums().next().is_none()); - let third = import(&mut s); - assert_eq!(third["imported"], 2); - assert!(s.catalog.photos().all(|photo| !photo.local)); - } - - #[test] - fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { - let mut s = crate::Session::new(); - let data = sample(); - let r = apply(&mut s, data.clone(), false).unwrap(); - assert_eq!(r["photos"], 2); - assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); - assert_eq!(r["missing"].as_array().unwrap().len(), 1); - let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); - assert_eq!(master.rating, 5); - assert_eq!(master.flag, Flag::Pick); - assert_eq!(master.meta.keywords, vec!["Travel|Goa"]); - assert_eq!(master.develop.light.exposure, 1.25); - let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap(); - assert_eq!(copy.copy_of, Some(master.id)); - assert_eq!(copy.rating, 2); - assert_eq!(copy.flag, Flag::Reject); - assert_eq!(copy.develop.light.exposure, -0.5); - let album = s.catalog.albums().find(|a| a.name == "Keepers").unwrap(); - assert_eq!(album.photos, vec![copy.id, master.id]); - assert!(album.parent.is_some()); - assert!(r["unmapped"]["1"].as_array().unwrap().iter().any(|v| v.as_str() == Some("CameraProfile"))); - assert_eq!(s.undo.len(), 1); - s.undo_step().unwrap(); - assert!(s.catalog.is_empty()); - assert!(s.catalog.albums().next().is_none()); - assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact - } - #[test] - fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { - let mut s = crate::Session::new(); - let data = sample(); - apply(&mut s, data.clone(), false).unwrap(); - let id = s.catalog.photos().find(|p| p.copy_of.is_none()).unwrap().id; - s.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal edit").unwrap(); - let r = apply(&mut s, data.clone(), false).unwrap(); - assert!(r["preservedExistingEdits"].as_u64().unwrap() > 0); - assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.0); - let before = s.catalog.to_snapshot(); - let mut cyclic = data; - cyclic.collections[0].insert("parent".into(), json!(10)); - assert!(apply(&mut s, cyclic, false).is_err()); - assert_eq!(s.catalog.to_snapshot(), before); - assert!( - mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) - .is_err() - ); - } - #[test] - fn compressed_xmp_is_bounded_and_settings_are_data_only() { - let packet = b"example"; - let mut encoded = (packet.len() as u32).to_be_bytes().to_vec(); - encoded.extend(miniz_oxide::deflate::compress_to_vec_zlib(packet, 6)); - assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); - encoded[0] = 0xff; - assert!(xmp(Some(&json!(encoded))).is_err()); - let (partial, _) = mapped_settings( - "s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", - crate::crs::Target::RawAbsolute, - 1.5, - Orientation::Normal, - 1.5, - ) - .unwrap(); - assert_eq!(partial["light"]["exposure"], 0.75); - } - - #[test] - fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { - // Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed - // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin - let settings = - "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); - assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); - assert_eq!(partial["light"]["exposure"], 0.5); - assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); - assert_eq!( - (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), - (Some("custom"), Some(3578.0), Some(-5.0)) - ); - assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}"); - } - - #[test] - fn catalog_crops_without_has_crop_are_imported() { - // Lightroom Classic catalogs store crop edges and angle but never `HasCrop` - let (partial, unknown) = mapped_settings( - "s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", - crate::crs::Target::RawAbsolute, - 1.5, - Orientation::Normal, - 1.5, - ) - .unwrap(); - let crop = &partial["crop"]["geometry"]; - assert_eq!(crop["rect"]["x0"], 0.042702); - assert_eq!(crop["rect"]["y0"], 0.1); - assert_eq!(crop["rect"]["x1"], 0.957298); - assert_eq!(crop["rect"]["y1"], 1.0); - assert_eq!(crop["angle"], 0.0); - assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); - // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) - let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); - assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); - assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); - // no crop fields: the crop stays untouched - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); - assert!(partial.get("crop").is_none()); - } - - #[test] - fn lightroom_orientation_codes() { - // the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8) - assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal)); - assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90)); - assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270)); - assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180)); - assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH)); - assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV)); - assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose)); - assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse)); - for code in ["", "A", "ABC", "AC", "ab", "XY"] { - assert_eq!(lightroom_orientation(code), None, "{code:?}"); - } - } - - #[test] - fn catalog_crops_follow_the_photo_orientation() { - let crop = |o: Orientation| { - let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); - let g = &partial["crop"]["geometry"]; - let r = &g["rect"]; - [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) - }; - // (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise - assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]); - assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]); - assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]); - assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]); - assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); - // a mirror turns the straighten the other way - let angle = |o: Orientation| { - let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); - partial["crop"]["geometry"]["angle"].as_f64().unwrap() - }; - assert_eq!(angle(Orientation::Rotate90), -2.0); - assert_eq!(angle(Orientation::FlipH), 2.0); - // no crop: nothing to orient - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); - assert!(partial.get("crop").is_none()); - } - - /// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image - /// of aspect `w` (height 1) shown with orientation `o`. - fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { - let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) }; - let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0)); - let stored = |x: f64, y: f64| { - let p = to_source.apply(Point::new(x * ow, y * oh)); - o.inverse().map(p.x, p.y, ow, oh) - }; - // the rectangle's corners that land top-left and bottom-right on the stored image - let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)]; - let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap(); - let angle = if o.to_parts().0 { g.angle } else { -g.angle }; - format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1) - } - - #[test] - fn straightened_catalog_crops_round_trip() { - let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 }; - for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { - for angle in [6.5, -6.5] { - let g = CropGeometry { angle, ..g }; - let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); - let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); - let close = |a: f64, b: f64| (a - b).abs() < 1e-9; - assert!( - close(got.angle, g.angle) - && [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)] - .iter() - .all(|&(a, b)| close(a, b)), - "{o:?} {angle}: {got:?}" - ); - } - } - } - - #[test] - fn straightened_catalog_crops_match_lightroom_exports() { - // Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the - // JPEGs Lightroom exported from it at full size. - for (settings, o, export) in [ - ( - "s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }", - Orientation::Normal, - (7725.0, 5150.0), - ), - ( - "s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }", - Orientation::Rotate90, - (6100.0, 9150.0), - ), - ] { - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); - let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); - let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; - let (w, h) = (r.width() * ow, r.height() * oh); - assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}"); - } - } - - #[test] - fn imported_crops_use_the_catalog_orientation() { - let mut data = sample(); - data.photos.truncate(1); - data.photos[0].image.remove("masterImage"); - data.photos[0].image.insert("orientation".into(), json!("BC")); - data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into(); - data.photos[0].xmp = String::new(); - let mut s = crate::Session::new(); - apply(&mut s, data, false).unwrap(); - let g = s.catalog.photos().next().unwrap().develop.crop.geometry; - let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9); - assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); - } - - #[test] - fn deferred_auto_tone_sentinels_never_become_real_slider_values() { - let mut data = sample(); - data.photos.truncate(1); - data.photos[0].image.remove("masterImage"); - data.photos[0].settings = - "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); - data.photos[0].xmp = r#""#.into(); - let mut s = crate::Session::new(); - let report = apply(&mut s, data, false).unwrap(); - let p = s.catalog.photos().next().unwrap(); - assert_eq!(p.develop.light.exposure, 0.0); - assert_eq!(p.develop.light.contrast, 0.0); - assert_eq!(p.develop.light.shadows, 65.0); - assert_eq!(p.develop.light.blacks, -22.0); - assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); - let (partial, _) = - mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) - .unwrap(); - assert_eq!(partial["light"]["exposure"], -5.0); - assert_eq!(partial["light"]["contrast"], -100.0); - } - - #[test] - fn wal_checksums_cover_commits_without_materializing_historical_databases() { - let mut main = vec![0; 512]; - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - assert_eq!(wal_checksum(&[1, 0, 0, 0, 2, 0, 0, 0], false, (0, 0)), (1, 3)); - for big in [false, true] { - let mut wal = vec![0; 32]; - wal[0..4].copy_from_slice(&(if big { 0x377f0683u32 } else { 0x377f0682u32 }).to_be_bytes()); - wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); - wal[8..12].copy_from_slice(&512u32.to_be_bytes()); - wal[16..20].copy_from_slice(&123u32.to_be_bytes()); - let mut sum = wal_checksum(&wal[..24], big, (0, 0)); - wal[24..28].copy_from_slice(&sum.0.to_be_bytes()); - wal[28..32].copy_from_slice(&sum.1.to_be_bytes()); - for _ in 0..128 { - let mut frame = vec![0; 536]; - frame[0..4].copy_from_slice(&1u32.to_be_bytes()); - frame[4..8].copy_from_slice(&1u32.to_be_bytes()); - frame[8..12].copy_from_slice(&123u32.to_be_bytes()); - sum = wal_checksum(&frame[..8], big, sum); - sum = wal_checksum(&frame[24..], big, sum); - frame[16..20].copy_from_slice(&sum.0.to_be_bytes()); - frame[20..24].copy_from_slice(&sum.1.to_be_bytes()); - wal.extend(frame); - } - assert_eq!(committed_wal_len(&main, &wal).unwrap(), wal.len()); - let end = wal.len(); - wal[end - 1] ^= 1; - assert_eq!(committed_wal_len(&main, &wal).unwrap(), end - 536); - wal[24] ^= 1; - assert!(committed_wal_len(&main, &wal).is_err()); - main[17] = 0; - assert!(committed_wal_len(&main, &wal).is_err()); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - } - } - - #[test] - fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id())); - let mut s = crate::Session::new(); - s.open_library(&dir, false).unwrap(); - let mut data = sample(); - data.collections.reverse(); // Parent must be committed before its child regardless of row order. - let first = apply(&mut s, data.clone(), false).unwrap(); - s.close_library().unwrap(); - s.open_library(&dir, false).unwrap(); - let id = PhotoId(first["mapping"]["1"].as_u64().unwrap()); - s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); - let second = apply(&mut s, data, true).unwrap(); - assert_eq!(first["mapping"], second["mapping"]); - assert_eq!(first["imported"], 2); - assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); - assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); - assert_eq!(second["library"], dir.to_string_lossy().as_ref()); - assert_eq!(s.catalog.len(), 2); - assert_eq!(s.catalog.albums().count(), 2); - assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); - s.close_library().unwrap(); - std::fs::remove_dir_all(dir).unwrap(); - } - - // Construct a small SQLite fixture from public file-format records, never private media. - fn varint(mut n: u64) -> Vec { - let mut out = vec![(n & 127) as u8]; - n >>= 7; - while n > 0 { - out.push((n & 127) as u8 | 128); - n >>= 7; - } - out.reverse(); - out - } - fn record(values: &[SqlValue]) -> Vec { - let mut header = Vec::new(); - let mut body = Vec::new(); - for value in values { - let serial = match value { - SqlValue::Null => 0, - SqlValue::Integer(n) => { - body.extend(n.to_be_bytes()); - 6 - } - SqlValue::Text(s) => { - body.extend(s.as_bytes()); - 13 + 2 * s.len() as u64 - } - _ => unreachable!(), - }; - header.extend(varint(serial)); - } - let mut out = varint(1 + header.len() as u64); - out.extend(header); - out.extend(body); - out - } - fn leaf(page: &mut [u8], offset: usize, rows: &[Vec]) { - page[offset] = 13; - page[offset + 3..offset + 5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); - let mut end = page.len(); - for (i, values) in rows.iter().enumerate() { - let payload = record(values); - let mut cell = varint(payload.len() as u64); - cell.extend(varint(i as u64 + 1)); - cell.extend(payload); - end -= cell.len(); - page[end..end + cell.len()].copy_from_slice(&cell); - page[offset + 8 + i * 2..offset + 10 + i * 2].copy_from_slice(&(end as u16).to_be_bytes()); - } - page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); - } - /// A one-row-per-table SQLite file: `(name, columns, row)` each. - fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { - native_catalog_with_last_table_page(tables, None) - } - /// [`native_catalog`] whose last table claims `last_page` as its root page. - fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec)], last_page: Option) -> Vec { - let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect(); - native_catalog_rows(&tables, last_page) - } - /// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last - /// table's root page. - fn native_catalog_rows(tables: &[(&str, &str, Vec>)], last_page: Option) -> Vec { - use SqlValue::{Integer as I, Text as T}; - let mut bytes = vec![0u8; 5 * 2048]; - bytes[..16].copy_from_slice(b"SQLite format 3\0"); - bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); - bytes[18] = 1; - bytes[19] = 1; - bytes[21] = 64; - bytes[22] = 32; - bytes[23] = 32; - bytes[28..32].copy_from_slice(&5u32.to_be_bytes()); - bytes[44..48].copy_from_slice(&4u32.to_be_bytes()); - bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); - let mut schema = Vec::new(); - for (i, (name, columns, rows)) in tables.iter().enumerate() { - schema.push(vec![ - T("table".into()), - T((*name).into()), - T((*name).into()), - I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }), - T(format!("CREATE TABLE {name} ({columns})")), - ]); - leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows); - } - leaf(&mut bytes[..2048], 100, &schema); - bytes - } - - fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec)> { - use SqlValue::{Integer as I, Null as N, Text as T}; - vec![ - ( - "AgLibraryRootFolder", - "id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog", - vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())], - ), - ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), - ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), - ( - "Adobe_images", - "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", - vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], - ), - ] - } - - #[test] - fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { - let bytes = native_catalog(&photo_tables(("/catalog/", ""))); - let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); - std::fs::write(&path, &bytes).unwrap(); - let catalog = read(&path).unwrap(); - assert_eq!(catalog.photos.len(), 1); - assert_eq!(catalog.photos[0].uuid, "fixture-image"); - assert_eq!(number(&catalog.photos[0].image, "rating"), 5); - assert!(catalog.photos[0].path.ends_with("test.ARW")); - // Put the required image table beyond the old 1 GiB cutoff without allocating it. - // A main-file-sized allocation would defeat the purpose of the page-backed reader. - // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere. - #[cfg(unix)] - { - let far_offset = 1u64 << 30; - let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64)); - use std::io::{Seek, SeekFrom, Write}; - let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap(); - sparse.write_all(&bytes).unwrap(); - sparse.set_len(far_offset + 2048).unwrap(); - sparse.seek(SeekFrom::Start(far_offset)).unwrap(); - sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap(); - drop(sparse); - let large = read(&path).unwrap(); - assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap()); - let mut session = crate::Session::new(); - session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(session.catalog.len(), 1); - assert_eq!(session.catalog.photos().next().unwrap().rating, 5); - session.execute("edit.undo", &json!({})).unwrap(); - assert!(session.catalog.is_empty()); - } - std::fs::write(&path, b"bad catalog").unwrap(); - assert!(read(&path).is_err()); - std::fs::remove_file(path).unwrap(); - } - - /// Reads `tables` as a catalog file and returns what the reader made of it. - fn read_tables(tables: &[(&str, &str, Vec>)], name: &str) -> CatalogImport { - let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id())); - std::fs::write(&path, native_catalog_rows(tables, None)).unwrap(); - let catalog = read(&path).unwrap(); - std::fs::remove_file(path).unwrap(); - catalog - } - - /// `tables` with each row as a one-row table, for [`native_catalog_rows`]. - fn one_row(tables: Vec<(&'static str, &'static str, Vec)>) -> Vec<(&'static str, &'static str, Vec>)> { - tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect() - } - - #[test] - fn images_with_incomplete_records_are_skipped_not_fatal() { - use SqlValue::{Integer as I, Text as T}; - // (table, column, value): the record that breaks, and the reason the import gives - let cases = [ - (3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file - (2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder - (1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root - (2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty - ]; - for (table, column, value, reason) in cases { - let mut tables = one_row(photo_tables(("/catalog/", ""))); - tables[table].2[0][column] = value; - let catalog = read_tables(&tables, &format!("skipped-{reason:?}")); - assert!(catalog.photos.is_empty(), "{reason:?}"); - assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]); - assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings); - } - } - - #[test] - fn a_virtual_copy_of_a_skipped_master_is_skipped_too() { - use SqlValue::{Integer as I, Null as N, Text as T}; - let mut tables = one_row(photo_tables(("/catalog/", ""))); - tables[3] = ( - "Adobe_images", - "id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick", - vec![ - // the master's file record is gone, so it is skipped … - vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)], - // … and its virtual copy goes with it, although its own file record is there - vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)], - ], - ); - let catalog = read_tables(&tables, "skipped-copy"); - assert!(catalog.photos.is_empty()); - assert_eq!( - catalog.skipped, - [SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }] - ); - validate(&catalog).unwrap(); - } - - #[test] - fn skipped_images_are_in_the_import_report() { - let mut data = sample(); - data.photos.retain(|p| p.source_id != 1); - drop_copies_of(&mut data, &HashSet::from([1])); - data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile }); - let mut s = crate::Session::new(); - let report = apply(&mut s, data, false).unwrap(); - assert_eq!(report["photos"], 0); - assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}])); - assert_eq!(report["warnings"], json!([])); - } - - #[test] - fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let source = dir.join("source.lrcat"); - let root = dir.join("offline"); - let tables = photo_tables((root.to_str().unwrap(), "")); - std::fs::write(&source, native_catalog(&tables)).unwrap(); - let mut s = crate::Session::new(); - let probes = Arc::new(AtomicUsize::new(0)); - let calls = probes.clone(); - s.media.file_probe = Some(Arc::new(move |_| { - calls.fetch_add(1, Ordering::Relaxed); - Err("a known-missing original must not be probed".into()) - })); - let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); - let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); - assert_eq!(probes.load(Ordering::Relaxed), 0); - let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); - assert_eq!(result.report["imported"], 1); - assert_eq!(result.report["failed"], json!([])); - assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); - let photo = s.catalog.photos().next().unwrap(); - assert_eq!(photo.rating, 5); - assert_eq!((photo.width, photo.height), (20, 10)); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.catalog.is_empty()); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn probed_original_is_counted_once_and_reimport_adds_nothing() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); - std::fs::create_dir_all(dir.join("Photos")).unwrap(); - let source = dir.join("source.lrcat"); - std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); - std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); - let mut s = crate::Session::new(); - let probes = Arc::new(AtomicUsize::new(0)); - let calls = probes.clone(); - s.media.file_probe = Some(Arc::new(move |_| { - calls.fetch_add(1, Ordering::Relaxed); - Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) - })); - for expected in [1, 0] { - let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); - let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); - let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); - assert_eq!(completion.report["imported"], expected); - assert_eq!(completion.report["missing"], json!([])); - assert_eq!(completion.report["failed"], json!([])); - assert_eq!(s.catalog.len(), 1); - } - assert_eq!(probes.load(Ordering::Relaxed), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn a_root_missing_here_is_found_relative_to_the_catalog() { - let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("Lightroom")).unwrap(); - std::fs::create_dir_all(dir.join("RAW")).unwrap(); - let path = dir.join("Lightroom").join("Lightroom.lrcat"); - std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap(); - let catalog = read(&path).unwrap(); - assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW")); - assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings); - // a relative path that leads nowhere keeps the recorded one - std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); - let catalog = read(&path).unwrap(); - assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); - assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn originals_on_other_computers_are_never_looked_at_or_added() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("Photos")).unwrap(); - std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); - let source = dir.join("source.lrcat"); - let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()]; - if cfg!(unix) { - // `///` is a local folder to Linux, but a network path to the resolver. - roots.push(format!("/{}/", dir.display())); - } - for root in &roots { - std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap(); - let catalog = read(&source).unwrap(); - assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]); - assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings); - let mut s = crate::Session::new(); - let probes = Arc::new(AtomicUsize::new(0)); - let calls = probes.clone(); - s.media.file_probe = Some(Arc::new(move |_| { - calls.fetch_add(1, Ordering::Relaxed); - Err("a refused original must not be probed".into()) - })); - let report = import(&mut s, &source, false).unwrap(); - assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}"); - assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}"); - assert!(s.catalog.is_empty(), "{root}"); - } - // An absolute folder path would replace the root. - let mut tables = photo_tables((dir.to_str().unwrap(), "")); - tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display())); - std::fs::write(&source, native_catalog(&tables)).unwrap(); - assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_))); - // A root beside the catalog is followed. - std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); - let catalog = read(&source).unwrap(); - assert_eq!(catalog.photos[0].original, Original::Local); - assert!(catalog.photos[0].original_is_file()); - // A root that moved is found through its path relative to the catalog. - std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap(); - let catalog = read(&source).unwrap(); - assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn oversized_optional_table_warns_and_is_skipped() { - let mut main = vec![0u8; 2 * 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 1; - main[19] = 1; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&2u32.to_be_bytes()); - main[44..48].copy_from_slice(&4u32.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - main[100] = 0x0d; - let page = &mut main[512..]; - page[0] = 0x0d; - page[3..5].copy_from_slice(&1u16.to_be_bytes()); - let mut cell = varint((64 * 1024 * 1024 + 1) as u64); - cell.extend(varint(1)); - let start = 500 - cell.len(); - page[start..start + cell.len()].copy_from_slice(&cell); - page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - let db = Database::open_with_wal(main, &[]).unwrap(); - let reader = - Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; - let mut warnings = Vec::new(); - let rows = reader.optional_table("optional_history", &mut warnings).unwrap(); - assert!(rows.is_empty()); - assert!(warnings.iter().any(|warning| warning.contains("optional_history"))); - } -} +mod tests; diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs new file mode 100644 index 000000000..654b40458 --- /dev/null +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -0,0 +1,810 @@ +use super::*; +use lightcraft_geom::CropGeometry; +fn row(v: Value) -> Row { + serde_json::from_value(v).unwrap() +} +fn sample() -> CatalogImport { + let mut photo = CatalogPhoto { + source_id: 1, + uuid: "test-master".into(), + path: "/missing/test.ARW".into(), + image: row(json!({"id_local":1,"rating":5,"pick":1,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})), + settings: "s = { Exposure2012 = 1.25, Contrast2012 = 17, CameraProfile = \"Example\" }".into(), + xmp: String::new(), + keywords: vec!["Travel|Goa".into()], + history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], + snapshots: Vec::new(), + original: Original::Local, + }; + let mut copy = photo.clone(); + copy.source_id = 2; + copy.uuid = "test-copy".into(); + copy.image = row(json!({"id_local":2,"masterImage":1,"copyName":"Mono","rating":2,"pick":-1})); + copy.settings = "s = { ConvertToGrayscale = true, Exposure2012 = -0.5 }".into(); + photo.image.insert("captureTime".into(), json!("2024-01-16T12:34:56")); + CatalogImport { + source: "test.lrcat".into(), + photos: vec![copy, photo], + collections: vec![ + row(json!({"id_local":9,"name":"Travel","creationId":"collection.group"})), + row(json!({"id_local":10,"parent":9,"name":"Keepers","creationId":"collection"})), + ], + members: vec![ + row(json!({"collection":10,"image":2,"positionInCollection":"a"})), + row(json!({"collection":10,"image":1,"positionInCollection":"b"})), + ], + collection_content: Vec::new(), + warnings: Vec::new(), + skipped: Vec::new(), + } +} +/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so +/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual +/// copies go with it; the catalog's real photos still import. +#[test] +fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { + let mut data = sample(); + let mut exe = data.photos[1].clone(); + exe.source_id = 3; + exe.uuid = "payload".into(); + exe.path = "/Users/sky/Downloads/payload.exe".into(); + exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); + let mut exe_copy = exe.clone(); + exe_copy.source_id = 4; + exe_copy.uuid = "payload-copy".into(); + exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); + exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); + // the copy comes before its master, as a catalog's table order may have it + data.photos.insert(0, exe_copy); + data.photos.push(exe); + for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { + let mut one = data.clone(); + one.photos.retain(|p| p.source_id < 3); + let mut planted = one.photos[1].clone(); + planted.source_id = 5; + planted.uuid = "planted".into(); + planted.path = bad.into(); + planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); + one.photos.push(planted); + let mut s = crate::Session::new(); + let r = apply(&mut s, one, false).unwrap(); + assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); + } + let mut s = crate::Session::new(); + let r = apply(&mut s, data, false).unwrap(); + assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); + assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); +} + +#[test] +fn offline_local_master_and_copy_join_library_with_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let master_id = s.catalog.alloc_photo_id(); + let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); + master.local = true; + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); + s.undo.clear(); + let before = s.catalog.photo(master_id).unwrap().clone(); + let mut index = ImportIndex::default(); + let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); + let mut import = |s: &mut crate::Session| { + let undo0 = s.undo.len(); + apply_prepared( + s, + data.clone(), + ApplyPrepared { + update_existing: false, + files: Default::default(), + index: &mut index, + archive_path: None, + missing_sources: &missing_sources, + undo0, + }, + ) + .unwrap() + .report + }; + let first = import(&mut s); + assert_eq!(first["mapping"]["1"], master_id.0); + assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); + assert_eq!(first["missing"], json!(["/missing/test.ARW"])); + assert_eq!(s.catalog.len(), 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + assert_eq!(s.undo.len(), 1); + let second = import(&mut s); + assert_eq!(second["imported"], 0); + assert_eq!(second["mapping"], first["mapping"]); + s.undo_step().unwrap(); + s.undo_step().unwrap(); + assert_eq!(s.catalog.len(), 1); + assert_eq!(s.catalog.photo(master_id), Some(&before)); + assert!(s.catalog.albums().next().is_none()); + let third = import(&mut s); + assert_eq!(third["imported"], 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); +} + +#[test] +fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let r = apply(&mut s, data.clone(), false).unwrap(); + assert_eq!(r["photos"], 2); + assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); + assert_eq!(r["missing"].as_array().unwrap().len(), 1); + let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); + assert_eq!(master.rating, 5); + assert_eq!(master.flag, Flag::Pick); + assert_eq!(master.meta.keywords, vec!["Travel|Goa"]); + assert_eq!(master.develop.light.exposure, 1.25); + let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap(); + assert_eq!(copy.copy_of, Some(master.id)); + assert_eq!(copy.rating, 2); + assert_eq!(copy.flag, Flag::Reject); + assert_eq!(copy.develop.light.exposure, -0.5); + let album = s.catalog.albums().find(|a| a.name == "Keepers").unwrap(); + assert_eq!(album.photos, vec![copy.id, master.id]); + assert!(album.parent.is_some()); + assert!(r["unmapped"]["1"].as_array().unwrap().iter().any(|v| v.as_str() == Some("CameraProfile"))); + assert_eq!(s.undo.len(), 1); + s.undo_step().unwrap(); + assert!(s.catalog.is_empty()); + assert!(s.catalog.albums().next().is_none()); + assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact +} +#[test] +fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { + let mut s = crate::Session::new(); + let data = sample(); + apply(&mut s, data.clone(), false).unwrap(); + let id = s.catalog.photos().find(|p| p.copy_of.is_none()).unwrap().id; + s.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal edit").unwrap(); + let r = apply(&mut s, data.clone(), false).unwrap(); + assert!(r["preservedExistingEdits"].as_u64().unwrap() > 0); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.0); + let before = s.catalog.to_snapshot(); + let mut cyclic = data; + cyclic.collections[0].insert("parent".into(), json!(10)); + assert!(apply(&mut s, cyclic, false).is_err()); + assert_eq!(s.catalog.to_snapshot(), before); + assert!( + mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) + .is_err() + ); +} +#[test] +fn compressed_xmp_is_bounded_and_settings_are_data_only() { + let packet = b"example"; + let mut encoded = (packet.len() as u32).to_be_bytes().to_vec(); + encoded.extend(miniz_oxide::deflate::compress_to_vec_zlib(packet, 6)); + assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); + encoded[0] = 0xff; + assert!(xmp(Some(&json!(encoded))).is_err()); + let (partial, _) = mapped_settings( + "s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", + crate::crs::Target::RawAbsolute, + 1.5, + Orientation::Normal, + 1.5, + ) + .unwrap(); + assert_eq!(partial["light"]["exposure"], 0.75); +} + +#[test] +fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { + // Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed + // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin + let settings = + "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); + assert_eq!(partial["light"]["exposure"], 0.5); + assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!( + (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), + (Some("custom"), Some(3578.0), Some(-5.0)) + ); + assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}"); +} + +#[test] +fn catalog_crops_without_has_crop_are_imported() { + // Lightroom Classic catalogs store crop edges and angle but never `HasCrop` + let (partial, unknown) = mapped_settings( + "s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", + crate::crs::Target::RawAbsolute, + 1.5, + Orientation::Normal, + 1.5, + ) + .unwrap(); + let crop = &partial["crop"]["geometry"]; + assert_eq!(crop["rect"]["x0"], 0.042702); + assert_eq!(crop["rect"]["y0"], 0.1); + assert_eq!(crop["rect"]["x1"], 0.957298); + assert_eq!(crop["rect"]["y1"], 1.0); + assert_eq!(crop["angle"], 0.0); + assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); + // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) + let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); + assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); + // no crop fields: the crop stays untouched + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert!(partial.get("crop").is_none()); +} + +#[test] +fn lightroom_orientation_codes() { + // the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8) + assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal)); + assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90)); + assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270)); + assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180)); + assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH)); + assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV)); + assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose)); + assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse)); + for code in ["", "A", "ABC", "AC", "ab", "XY"] { + assert_eq!(lightroom_orientation(code), None, "{code:?}"); + } +} + +#[test] +fn catalog_crops_follow_the_photo_orientation() { + let crop = |o: Orientation| { + let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let g = &partial["crop"]["geometry"]; + let r = &g["rect"]; + [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) + }; + // (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise + assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]); + assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]); + assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]); + assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]); + assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); + // a mirror turns the straighten the other way + let angle = |o: Orientation| { + let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + partial["crop"]["geometry"]["angle"].as_f64().unwrap() + }; + assert_eq!(angle(Orientation::Rotate90), -2.0); + assert_eq!(angle(Orientation::FlipH), 2.0); + // no crop: nothing to orient + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); + assert!(partial.get("crop").is_none()); +} + +/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image +/// of aspect `w` (height 1) shown with orientation `o`. +fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { + let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) }; + let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0)); + let stored = |x: f64, y: f64| { + let p = to_source.apply(Point::new(x * ow, y * oh)); + o.inverse().map(p.x, p.y, ow, oh) + }; + // the rectangle's corners that land top-left and bottom-right on the stored image + let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)]; + let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap(); + let angle = if o.to_parts().0 { g.angle } else { -g.angle }; + format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1) +} + +#[test] +fn straightened_catalog_crops_round_trip() { + let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 }; + for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { + for angle in [6.5, -6.5] { + let g = CropGeometry { angle, ..g }; + let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert!( + close(got.angle, g.angle) + && [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)] + .iter() + .all(|&(a, b)| close(a, b)), + "{o:?} {angle}: {got:?}" + ); + } + } +} + +#[test] +fn straightened_catalog_crops_match_lightroom_exports() { + // Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the + // JPEGs Lightroom exported from it at full size. + for (settings, o, export) in [ + ( + "s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }", + Orientation::Normal, + (7725.0, 5150.0), + ), + ( + "s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }", + Orientation::Rotate90, + (6100.0, 9150.0), + ), + ] { + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); + let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); + let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; + let (w, h) = (r.width() * ow, r.height() * oh); + assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}"); + } +} + +#[test] +fn imported_crops_use_the_catalog_orientation() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].image.insert("orientation".into(), json!("BC")); + data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into(); + data.photos[0].xmp = String::new(); + let mut s = crate::Session::new(); + apply(&mut s, data, false).unwrap(); + let g = s.catalog.photos().next().unwrap().develop.crop.geometry; + let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9); + assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); +} + +#[test] +fn deferred_auto_tone_sentinels_never_become_real_slider_values() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].settings = "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); + data.photos[0].xmp = r#""#.into(); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.develop.light.exposure, 0.0); + assert_eq!(p.develop.light.contrast, 0.0); + assert_eq!(p.develop.light.shadows, 65.0); + assert_eq!(p.develop.light.blacks, -22.0); + assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); + let (partial, _) = + mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5).unwrap(); + assert_eq!(partial["light"]["exposure"], -5.0); + assert_eq!(partial["light"]["contrast"], -100.0); +} + +#[test] +fn wal_checksums_cover_commits_without_materializing_historical_databases() { + let mut main = vec![0; 512]; + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + assert_eq!(wal_checksum(&[1, 0, 0, 0, 2, 0, 0, 0], false, (0, 0)), (1, 3)); + for big in [false, true] { + let mut wal = vec![0; 32]; + wal[0..4].copy_from_slice(&(if big { 0x377f0683u32 } else { 0x377f0682u32 }).to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[16..20].copy_from_slice(&123u32.to_be_bytes()); + let mut sum = wal_checksum(&wal[..24], big, (0, 0)); + wal[24..28].copy_from_slice(&sum.0.to_be_bytes()); + wal[28..32].copy_from_slice(&sum.1.to_be_bytes()); + for _ in 0..128 { + let mut frame = vec![0; 536]; + frame[0..4].copy_from_slice(&1u32.to_be_bytes()); + frame[4..8].copy_from_slice(&1u32.to_be_bytes()); + frame[8..12].copy_from_slice(&123u32.to_be_bytes()); + sum = wal_checksum(&frame[..8], big, sum); + sum = wal_checksum(&frame[24..], big, sum); + frame[16..20].copy_from_slice(&sum.0.to_be_bytes()); + frame[20..24].copy_from_slice(&sum.1.to_be_bytes()); + wal.extend(frame); + } + assert_eq!(committed_wal_len(&main, &wal).unwrap(), wal.len()); + let end = wal.len(); + wal[end - 1] ^= 1; + assert_eq!(committed_wal_len(&main, &wal).unwrap(), end - 536); + wal[24] ^= 1; + assert!(committed_wal_len(&main, &wal).is_err()); + main[17] = 0; + assert!(committed_wal_len(&main, &wal).is_err()); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + } +} + +#[test] +fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id())); + let mut s = crate::Session::new(); + s.open_library(&dir, false).unwrap(); + let mut data = sample(); + data.collections.reverse(); // Parent must be committed before its child regardless of row order. + let first = apply(&mut s, data.clone(), false).unwrap(); + s.close_library().unwrap(); + s.open_library(&dir, false).unwrap(); + let id = PhotoId(first["mapping"]["1"].as_u64().unwrap()); + s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); + let second = apply(&mut s, data, true).unwrap(); + assert_eq!(first["mapping"], second["mapping"]); + assert_eq!(first["imported"], 2); + assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); + assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); + assert_eq!(second["library"], dir.to_string_lossy().as_ref()); + assert_eq!(s.catalog.len(), 2); + assert_eq!(s.catalog.albums().count(), 2); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); + s.close_library().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} + +// Construct a small SQLite fixture from public file-format records, never private media. +fn varint(mut n: u64) -> Vec { + let mut out = vec![(n & 127) as u8]; + n >>= 7; + while n > 0 { + out.push((n & 127) as u8 | 128); + n >>= 7; + } + out.reverse(); + out +} +fn record(values: &[SqlValue]) -> Vec { + let mut header = Vec::new(); + let mut body = Vec::new(); + for value in values { + let serial = match value { + SqlValue::Null => 0, + SqlValue::Integer(n) => { + body.extend(n.to_be_bytes()); + 6 + } + SqlValue::Text(s) => { + body.extend(s.as_bytes()); + 13 + 2 * s.len() as u64 + } + _ => unreachable!(), + }; + header.extend(varint(serial)); + } + let mut out = varint(1 + header.len() as u64); + out.extend(header); + out.extend(body); + out +} +fn leaf(page: &mut [u8], offset: usize, rows: &[Vec]) { + page[offset] = 13; + page[offset + 3..offset + 5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); + let mut end = page.len(); + for (i, values) in rows.iter().enumerate() { + let payload = record(values); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(i as u64 + 1)); + cell.extend(payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + page[offset + 8 + i * 2..offset + 10 + i * 2].copy_from_slice(&(end as u16).to_be_bytes()); + } + page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); +} +/// A one-row-per-table SQLite file: `(name, columns, row)` each. +fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { + native_catalog_with_last_table_page(tables, None) +} +/// [`native_catalog`] whose last table claims `last_page` as its root page. +fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec)], last_page: Option) -> Vec { + let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect(); + native_catalog_rows(&tables, last_page) +} +/// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last +/// table's root page. +fn native_catalog_rows(tables: &[(&str, &str, Vec>)], last_page: Option) -> Vec { + use SqlValue::{Integer as I, Text as T}; + let mut bytes = vec![0u8; 5 * 2048]; + bytes[..16].copy_from_slice(b"SQLite format 3\0"); + bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); + bytes[18] = 1; + bytes[19] = 1; + bytes[21] = 64; + bytes[22] = 32; + bytes[23] = 32; + bytes[28..32].copy_from_slice(&5u32.to_be_bytes()); + bytes[44..48].copy_from_slice(&4u32.to_be_bytes()); + bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); + let mut schema = Vec::new(); + for (i, (name, columns, rows)) in tables.iter().enumerate() { + schema.push(vec![ + T("table".into()), + T((*name).into()), + T((*name).into()), + I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }), + T(format!("CREATE TABLE {name} ({columns})")), + ]); + leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows); + } + leaf(&mut bytes[..2048], 100, &schema); + bytes +} + +fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec)> { + use SqlValue::{Integer as I, Null as N, Text as T}; + vec![ + ( + "AgLibraryRootFolder", + "id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog", + vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())], + ), + ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), + ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), + ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", + vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], + ), + ] +} + +#[test] +fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { + let bytes = native_catalog(&photo_tables(("/catalog/", ""))); + let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(catalog.photos.len(), 1); + assert_eq!(catalog.photos[0].uuid, "fixture-image"); + assert_eq!(number(&catalog.photos[0].image, "rating"), 5); + assert!(catalog.photos[0].path.ends_with("test.ARW")); + // Put the required image table beyond the old 1 GiB cutoff without allocating it. + // A main-file-sized allocation would defeat the purpose of the page-backed reader. + // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere. + #[cfg(unix)] + { + let far_offset = 1u64 << 30; + let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64)); + use std::io::{Seek, SeekFrom, Write}; + let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap(); + sparse.write_all(&bytes).unwrap(); + sparse.set_len(far_offset + 2048).unwrap(); + sparse.seek(SeekFrom::Start(far_offset)).unwrap(); + sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap(); + drop(sparse); + let large = read(&path).unwrap(); + assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap()); + let mut session = crate::Session::new(); + session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(session.catalog.len(), 1); + assert_eq!(session.catalog.photos().next().unwrap().rating, 5); + session.execute("edit.undo", &json!({})).unwrap(); + assert!(session.catalog.is_empty()); + } + std::fs::write(&path, b"bad catalog").unwrap(); + assert!(read(&path).is_err()); + std::fs::remove_file(path).unwrap(); +} + +/// Reads `tables` as a catalog file and returns what the reader made of it. +fn read_tables(tables: &[(&str, &str, Vec>)], name: &str) -> CatalogImport { + let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id())); + std::fs::write(&path, native_catalog_rows(tables, None)).unwrap(); + let catalog = read(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + catalog +} + +/// `tables` with each row as a one-row table, for [`native_catalog_rows`]. +fn one_row(tables: Vec<(&'static str, &'static str, Vec)>) -> Vec<(&'static str, &'static str, Vec>)> { + tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect() +} + +#[test] +fn images_with_incomplete_records_are_skipped_not_fatal() { + use SqlValue::{Integer as I, Text as T}; + // (table, column, value): the record that breaks, and the reason the import gives + let cases = [ + (3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file + (2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder + (1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root + (2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty + ]; + for (table, column, value, reason) in cases { + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[table].2[0][column] = value; + let catalog = read_tables(&tables, &format!("skipped-{reason:?}")); + assert!(catalog.photos.is_empty(), "{reason:?}"); + assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]); + assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings); + } +} + +#[test] +fn a_virtual_copy_of_a_skipped_master_is_skipped_too() { + use SqlValue::{Integer as I, Null as N, Text as T}; + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[3] = ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick", + vec![ + // the master's file record is gone, so it is skipped … + vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)], + // … and its virtual copy goes with it, although its own file record is there + vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)], + ], + ); + let catalog = read_tables(&tables, "skipped-copy"); + assert!(catalog.photos.is_empty()); + assert_eq!( + catalog.skipped, + [SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }] + ); + validate(&catalog).unwrap(); +} + +#[test] +fn skipped_images_are_in_the_import_report() { + let mut data = sample(); + data.photos.retain(|p| p.source_id != 1); + drop_copies_of(&mut data, &HashSet::from([1])); + data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile }); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + assert_eq!(report["photos"], 0); + assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}])); + assert_eq!(report["warnings"], json!([])); +} + +#[test] +fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.lrcat"); + let root = dir.join("offline"); + let tables = photo_tables((root.to_str().unwrap(), "")); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a known-missing original must not be probed".into()) + })); + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0); + let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(result.report["imported"], 1); + assert_eq!(result.report["failed"], json!([])); + assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); + let photo = s.catalog.photos().next().unwrap(); + assert_eq!(photo.rating, 5); + assert_eq!((photo.width, photo.height), (20, 10)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn probed_original_is_counted_once_and_reimport_adds_nothing() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + let source = dir.join("source.lrcat"); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) + })); + for expected in [1, 0] { + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(completion.report["imported"], expected); + assert_eq!(completion.report["missing"], json!([])); + assert_eq!(completion.report["failed"], json!([])); + assert_eq!(s.catalog.len(), 1); + } + assert_eq!(probes.load(Ordering::Relaxed), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn a_root_missing_here_is_found_relative_to_the_catalog() { + let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Lightroom")).unwrap(); + std::fs::create_dir_all(dir.join("RAW")).unwrap(); + let path = dir.join("Lightroom").join("Lightroom.lrcat"); + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW")); + assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings); + // a relative path that leads nowhere keeps the recorded one + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); + assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn originals_on_other_computers_are_never_looked_at_or_added() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + let source = dir.join("source.lrcat"); + let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()]; + if cfg!(unix) { + // `///` is a local folder to Linux, but a network path to the resolver. + roots.push(format!("/{}/", dir.display())); + } + for root in &roots { + std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]); + assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a refused original must not be probed".into()) + })); + let report = import(&mut s, &source, false).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}"); + assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}"); + assert!(s.catalog.is_empty(), "{root}"); + } + // An absolute folder path would replace the root. + let mut tables = photo_tables((dir.to_str().unwrap(), "")); + tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display())); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_))); + // A root beside the catalog is followed. + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert_eq!(catalog.photos[0].original, Original::Local); + assert!(catalog.photos[0].original_is_file()); + // A root that moved is found through its path relative to the catalog. + std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn oversized_optional_table_warns_and_is_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + main[100] = 0x0d; + let page = &mut main[512..]; + page[0] = 0x0d; + page[3..5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint((64 * 1024 * 1024 + 1) as u64); + cell.extend(varint(1)); + let start = 500 - cell.len(); + page[start..start + cell.len()].copy_from_slice(&cell); + page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let db = Database::open_with_wal(main, &[]).unwrap(); + let reader = Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; + let mut warnings = Vec::new(); + let rows = reader.optional_table("optional_history", &mut warnings).unwrap(); + assert!(rows.is_empty()); + assert!(warnings.iter().any(|warning| warning.contains("optional_history"))); +} diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 58019f2c1..60e03281c 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -963,420 +963,4 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - struct Scratch(PathBuf); - impl Scratch { - fn new(bytes: &[u8]) -> Self { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id())); - std::fs::write(&path, bytes).unwrap(); - Self(path) - } - } - impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } - } - - fn file_backed(mut db: Database) -> (Scratch, Database) { - let scratch = Scratch::new(&db.main); - db.file = Some(FileSource::open(&scratch.0).unwrap()); - db.main.truncate(100); - (scratch, db) - } - - #[test] - fn file_source_bounds_and_mutation_detection() { - let scratch = Scratch::new(&[0; 512]); - let source = FileSource::open(&scratch.0).unwrap(); - assert_eq!(source.page(1, 512).unwrap().len(), 512); - assert!(source.page(2, 512).unwrap_err().contains("truncated")); - File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap(); - assert!(source.page(1, 512).is_err()); - assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); - drop(source); - // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows. - #[cfg(unix)] - { - File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap(); - assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit"); - } - } - - #[cfg(unix)] - #[test] - fn file_source_detects_same_sized_path_replacement() { - let original = Scratch::new(&[0; 512]); - let replacement = Scratch::new(&[0; 512]); - let source = FileSource::open(&original.0).unwrap(); - std::fs::rename(&replacement.0, &original.0).unwrap(); - assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); - } - - #[test] - fn file_source_detects_same_length_content_changes() { - let scratch = Scratch::new(&[0; 512]); - let source = FileSource::open(&scratch.0).unwrap(); - std::fs::write(&scratch.0, [1; 512]).unwrap(); - // Make the timestamp change deterministic even on filesystems with coarse timestamps. - File::options() - .write(true) - .open(&scratch.0) - .unwrap() - .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2)) - .unwrap(); - assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); - } - - fn varint(mut value: u64) -> Vec { - let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; - value >>= 7; - while value != 0 { - out.push(u8::try_from(value & 0x7f).unwrap_or(0) | 0x80); - value >>= 7; - } - out.reverse(); - out - } - - fn leaf(page: &mut [u8], base: usize, rowid: u64, payload: &[u8]) { - page[base] = 0x0d; - page[base + 3..base + 5].copy_from_slice(&1u16.to_be_bytes()); - let mut cell = varint(payload.len() as u64); - cell.extend(varint(rowid)); - cell.extend(payload); - let start = page.len() - cell.len(); - page[start..].copy_from_slice(&cell); - page[base + 8..base + 10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - page[base + 5..base + 7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - } - - fn leaf_rows(page: &mut [u8], base: usize, rows: &[(u64, &[u8])]) { - page[base] = 0x0d; - page[base + 3..base + 5].copy_from_slice(&u16::try_from(rows.len()).unwrap_or(0).to_be_bytes()); - let mut end = page.len(); - for (index, (rowid, payload)) in rows.iter().enumerate() { - let mut cell = varint(payload.len() as u64); - cell.extend(varint(*rowid)); - cell.extend(*payload); - end -= cell.len(); - page[end..end + cell.len()].copy_from_slice(&cell); - let pointer = base + 8 + index * 2; - page[pointer..pointer + 2].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); - } - page[base + 5..base + 7].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); - } - - fn schema_record(name: &str, root: u8, sql: &str) -> Vec { - let mut header = vec![23]; - let text_serial = |text: &str| 13 + 2 * text.len() as u64; - header.extend(varint(text_serial(name))); - header.extend(varint(text_serial(name))); - header.push(1); - header.extend(varint(text_serial(sql))); - let mut body = b"table".to_vec(); - body.extend(name.as_bytes()); - body.extend(name.as_bytes()); - body.push(root); - body.extend(sql.as_bytes()); - let mut record = varint((1 + header.len()) as u64); - record.extend(header); - record.extend(body); - record - } - - #[test] - fn schema_names_and_integer_primary_key_alias_are_decoded() { - let sql = r#"CREATE TABLE "t" ("id_local" INTEGER PRIMARY KEY, "odd,name" TEXT, PRIMARY KEY ("id_local"))"#; - let (names, alias) = parse_create_table(sql).unwrap(); - assert_eq!(names, vec!["id_local", "odd,name"]); - assert_eq!(alias, Some(0)); - let (_, no_alias) = parse_create_table("CREATE TABLE t (id INTEGER PRIMARY KEY DESC, value TEXT)").unwrap(); - assert_eq!(no_alias, None); - let (table_names, table_alias) = parse_create_table("CREATE TABLE t (id INTEGER, value TEXT, PRIMARY KEY(id))").unwrap(); - assert_eq!(table_names, vec!["id", "value"]); - assert_eq!(table_alias, Some(0)); - } - - /// A database file with no schema yet has schema format 0 in its header (file format - /// §1.3.10): a catalog created in WAL mode whose tables are all still in its `-wal` file (one - /// Lightroom has open and hasn't checkpointed) was refused as "unsupported SQLite schema - /// format" before the WAL was looked at. It opens, with no tables; other formats stay refused. - #[test] - fn schema_format_zero_is_an_empty_database() { - let header = |format: u32| { - let mut main = vec![0u8; 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 2; - main[19] = 2; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&1u32.to_be_bytes()); - main[44..48].copy_from_slice(&format.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - main[100] = 0x0d; - main[105..107].copy_from_slice(&512u16.to_be_bytes()); - main - }; - let db = Database::open_with_wal(header(0), &[]).unwrap(); - assert!(db.live_tables().unwrap().is_empty()); - assert!(Database::open_with_wal(header(4), &[]).is_ok()); - assert_eq!(Database::open_with_wal(header(5), &[]).err().as_deref(), Some("unsupported SQLite schema format")); - } - - #[test] - fn unsupported_unrelated_schema_entries_are_skipped() { - let mut main = vec![0u8; 2 * 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 1; - main[19] = 1; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&2u32.to_be_bytes()); - main[44..48].copy_from_slice(&4u32.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - let virtual_sql = "CREATE VIRTUAL TABLE unrelated USING fts5(text)"; - let supported_sql = "CREATE TABLE supported (id INTEGER PRIMARY KEY)"; - let virtual_record = schema_record("unrelated", 2, virtual_sql); - let supported_record = schema_record("supported", 2, supported_sql); - leaf_rows(&mut main[..512], 100, &[(1, &virtual_record), (2, &supported_record)]); - let db = Database::open_with_wal(main, &[]).unwrap(); - let tables = db.live_tables().unwrap(); - assert_eq!(tables.len(), 1); - assert_eq!(tables[0].name, "supported"); - } - - #[test] - fn synthetic_btree_substitutes_rowid_and_reads_schema() { - let mut main = vec![0u8; 2 * 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 1; - main[19] = 1; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&2u32.to_be_bytes()); - main[44..48].copy_from_slice(&4u32.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - let sql = "CREATE TABLE \"t\" (\"id_local\" INTEGER PRIMARY KEY, \"name\" TEXT)"; - // rootpage is an integer field, so replace fourth text value with a compact record. - let mut header = vec![23, 15, 15, 1]; - header.extend(varint((13 + 2 * sql.len()) as u64)); - let mut body = b"tablett".to_vec(); - body.push(2); - body.extend(sql.as_bytes()); - let mut record = varint((1 + header.len()) as u64); - record.append(&mut header); - record.extend(body); - let schema = record; - leaf(&mut main[512..], 0, 1, &[3, 0, 15, b'x']); - leaf(&mut main[..512], 100, 1, &schema); - let db = Database::open_with_wal(main, &[]).unwrap(); - let tables = db.live_tables().unwrap(); - assert!(tables.iter().any(|table| table.name == "t")); - let rows = db.read_table(2, 2).unwrap(); - assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); - let scratch = Scratch::new(&db.main); - let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap(); - assert_eq!(file_db.live_tables().unwrap(), tables); - assert_eq!(file_db.read_table(2, 2).unwrap(), rows); - assert_eq!(file_db.main.len(), 100); - file_db.ensure_unchanged().unwrap(); - // The same committed WAL page overrides the lazily read main page. - let mut wal = vec![0; 32 + 24 + 512]; - wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); - wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); - wal[8..12].copy_from_slice(&512u32.to_be_bytes()); - wal[32..36].copy_from_slice(&2u32.to_be_bytes()); - wal[36..40].copy_from_slice(&2u32.to_be_bytes()); - leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']); - let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap(); - let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap(); - assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap()); - assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2)); - let mut malformed = db.main.clone(); - malformed.push(0); - let partial = Scratch::new(&malformed); - assert_eq!( - Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(), - "SQLite image is not a whole number of pages" - ); - let mut malformed = db.main.clone(); - malformed[24..28].copy_from_slice(&1u32.to_be_bytes()); - malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); - malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); - let oversized = Scratch::new(&malformed); - assert_eq!( - Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), - "SQLite header page count exceeds image" - ); - } - - #[test] - fn interior_table_pointer_array_uses_cell_offsets() { - let mut main = vec![0u8; 4 * 512]; - let mut interior = vec![0u8; 512]; - interior[0] = 0x05; - interior[3..5].copy_from_slice(&1u16.to_be_bytes()); - interior[8..12].copy_from_slice(&4u32.to_be_bytes()); - interior[12..14].copy_from_slice(&500u16.to_be_bytes()); - interior[500..504].copy_from_slice(&3u32.to_be_bytes()); - interior[504] = 1; - main[512..1024].copy_from_slice(&interior); - leaf(&mut main[1024..1536], 0, 1, &[2, 0]); - leaf(&mut main[1536..], 0, 2, &[2, 0]); - let db = Database { - file: None, - main, - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 4, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - assert_eq!(db.read_table(2, 1).unwrap().len(), 2); - } - - #[test] - fn negative_rowid_varint_is_signed() { - let mut main = vec![0u8; 512]; - let page = &mut main[..]; - page[100] = 0x0d; - page[103..105].copy_from_slice(&1u16.to_be_bytes()); - let start = 500usize; - page[start] = 2; - for offset in 1..10 { - if let Some(byte) = page.get_mut(start + offset) { - *byte = 0xff; - } - } - page[start + 10..start + 12].copy_from_slice(&[2, 0]); - page[108..110].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - page[105..107].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - let mut aliases = HashMap::new(); - aliases.insert(1, 0); - let db = Database { - file: None, - main, - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 1, - tables: Vec::new(), - rowid_aliases: aliases, - }; - let rows = db.read_table(1, 1).unwrap(); - assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(-1))); - } - - #[test] - fn reserved_page_trailer_cannot_supply_cells() { - let mut main = vec![0u8; 512]; - main[100] = 0x0d; - main[103..105].copy_from_slice(&1u16.to_be_bytes()); - main[108..110].copy_from_slice(&490u16.to_be_bytes()); - let db = Database { - file: None, - main, - overlay: HashMap::new(), - page_size: 512, - reserved: 32, - encoding: TextEncoding::Utf8, - page_count: 1, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - assert!(db.read_table(1, 1).is_err()); - let (_scratch, db) = file_backed(db); - assert!(db.read_table(1, 1).is_err()); - } - - #[test] - fn utf16_and_overflow_payloads_are_supported() { - let mut payload = vec![2, 17, b'A', 0]; - assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Le).unwrap(), vec![Value::Text("A".into())]); - payload.clear(); - payload.extend([2, 17, 0, b'A']); - assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Be).unwrap(), vec![Value::Text("A".into())]); - - let mut main = vec![0u8; 2 * 512]; - let db = Database { - file: None, - main: std::mem::take(&mut main), - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 2, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - let mut cell_page = vec![b'x'; 512]; - // For a 512-byte table leaf & 600-byte payload, SQLite stores 92 - // payload bytes locally, followed by the overflow page pointer. - cell_page[92..96].copy_from_slice(&2u32.to_be_bytes()); - let inline_page = vec![b'i'; 512]; - assert_eq!(db.payload(&inline_page, 0, 110).unwrap(), vec![b'i'; 110]); - let mut overflow_page = vec![b'y'; 512]; - overflow_page[..4].copy_from_slice(&0u32.to_be_bytes()); - let mut db = db; - db.main[512..].copy_from_slice(&overflow_page); - let mut with_overflow = db.overlay; - with_overflow.insert(2, overflow_page); - db.overlay = with_overflow; - let bytes = db.payload(&cell_page, 0, 600).unwrap(); - assert_eq!(bytes.len(), 600); - assert_eq!(&bytes[..92], &[b'x'; 92]); - assert_eq!(&bytes[92..], &[b'y'; 508]); - let (_scratch, mut db) = file_backed(db); - db.overlay.clear(); - let bytes = db.payload(&cell_page, 0, 600).unwrap(); - assert_eq!(&bytes[92..], &[b'y'; 508]); - } - - #[test] - fn malformed_varint_and_wal_cycle_fail_cleanly() { - let mut at = 0; - assert!(read_varint(&[0x80; 8], &mut at).is_err()); - let mut wal = vec![0u8; 32 + 24 + 512]; - wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); - wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); - wal[8..12].copy_from_slice(&512u32.to_be_bytes()); - wal[16..20].copy_from_slice(&7u32.to_be_bytes()); - wal[20..24].copy_from_slice(&8u32.to_be_bytes()); - wal[32..36].copy_from_slice(&1u32.to_be_bytes()); - wal[36..40].copy_from_slice(&1u32.to_be_bytes()); - wal[40..44].copy_from_slice(&7u32.to_be_bytes()); - wal[44..48].copy_from_slice(&8u32.to_be_bytes()); - let parsed = parse_wal(&wal, 512).unwrap(); - assert_eq!(parsed.frame_count, 1); - assert_eq!(parsed.page_count, Some(1)); - assert_eq!(parsed.pages.get(&1).map(Vec::len), Some(512)); - let mut page = vec![0u8; 512]; - page[16..18].copy_from_slice(&512u16.to_be_bytes()); - let db = Database { - file: None, - main: vec![0; 512], - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 1, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - assert!(db.payload(&page, 0, 600).is_err()); - } -} +mod tests; diff --git a/crates/engine/src/lightroom_sqlite/tests.rs b/crates/engine/src/lightroom_sqlite/tests.rs new file mode 100644 index 000000000..f34160ff7 --- /dev/null +++ b/crates/engine/src/lightroom_sqlite/tests.rs @@ -0,0 +1,412 @@ +use super::*; + +struct Scratch(PathBuf); +impl Scratch { + fn new(bytes: &[u8]) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + Self(path) + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn file_backed(mut db: Database) -> (Scratch, Database) { + let scratch = Scratch::new(&db.main); + db.file = Some(FileSource::open(&scratch.0).unwrap()); + db.main.truncate(100); + (scratch, db) +} + +#[test] +fn file_source_bounds_and_mutation_detection() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + assert_eq!(source.page(1, 512).unwrap().len(), 512); + assert!(source.page(2, 512).unwrap_err().contains("truncated")); + File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap(); + assert!(source.page(1, 512).is_err()); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + drop(source); + // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows. + #[cfg(unix)] + { + File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap(); + assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit"); + } +} + +#[cfg(unix)] +#[test] +fn file_source_detects_same_sized_path_replacement() { + let original = Scratch::new(&[0; 512]); + let replacement = Scratch::new(&[0; 512]); + let source = FileSource::open(&original.0).unwrap(); + std::fs::rename(&replacement.0, &original.0).unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); +} + +#[test] +fn file_source_detects_same_length_content_changes() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + std::fs::write(&scratch.0, [1; 512]).unwrap(); + // Make the timestamp change deterministic even on filesystems with coarse timestamps. + File::options() + .write(true) + .open(&scratch.0) + .unwrap() + .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2)) + .unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); +} + +fn varint(mut value: u64) -> Vec { + let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; + value >>= 7; + while value != 0 { + out.push(u8::try_from(value & 0x7f).unwrap_or(0) | 0x80); + value >>= 7; + } + out.reverse(); + out +} + +fn leaf(page: &mut [u8], base: usize, rowid: u64, payload: &[u8]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(rowid)); + cell.extend(payload); + let start = page.len() - cell.len(); + page[start..].copy_from_slice(&cell); + page[base + 8..base + 10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[base + 5..base + 7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); +} + +fn leaf_rows(page: &mut [u8], base: usize, rows: &[(u64, &[u8])]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&u16::try_from(rows.len()).unwrap_or(0).to_be_bytes()); + let mut end = page.len(); + for (index, (rowid, payload)) in rows.iter().enumerate() { + let mut cell = varint(payload.len() as u64); + cell.extend(varint(*rowid)); + cell.extend(*payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + let pointer = base + 8 + index * 2; + page[pointer..pointer + 2].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); + } + page[base + 5..base + 7].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); +} + +fn schema_record(name: &str, root: u8, sql: &str) -> Vec { + let mut header = vec![23]; + let text_serial = |text: &str| 13 + 2 * text.len() as u64; + header.extend(varint(text_serial(name))); + header.extend(varint(text_serial(name))); + header.push(1); + header.extend(varint(text_serial(sql))); + let mut body = b"table".to_vec(); + body.extend(name.as_bytes()); + body.extend(name.as_bytes()); + body.push(root); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.extend(header); + record.extend(body); + record +} + +#[test] +fn schema_names_and_integer_primary_key_alias_are_decoded() { + let sql = r#"CREATE TABLE "t" ("id_local" INTEGER PRIMARY KEY, "odd,name" TEXT, PRIMARY KEY ("id_local"))"#; + let (names, alias) = parse_create_table(sql).unwrap(); + assert_eq!(names, vec!["id_local", "odd,name"]); + assert_eq!(alias, Some(0)); + let (_, no_alias) = parse_create_table("CREATE TABLE t (id INTEGER PRIMARY KEY DESC, value TEXT)").unwrap(); + assert_eq!(no_alias, None); + let (table_names, table_alias) = parse_create_table("CREATE TABLE t (id INTEGER, value TEXT, PRIMARY KEY(id))").unwrap(); + assert_eq!(table_names, vec!["id", "value"]); + assert_eq!(table_alias, Some(0)); +} + +/// A database file with no schema yet has schema format 0 in its header (file format +/// §1.3.10): a catalog created in WAL mode whose tables are all still in its `-wal` file (one +/// Lightroom has open and hasn't checkpointed) was refused as "unsupported SQLite schema +/// format" before the WAL was looked at. It opens, with no tables; other formats stay refused. +#[test] +fn schema_format_zero_is_an_empty_database() { + let header = |format: u32| { + let mut main = vec![0u8; 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 2; + main[19] = 2; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&1u32.to_be_bytes()); + main[44..48].copy_from_slice(&format.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + main[100] = 0x0d; + main[105..107].copy_from_slice(&512u16.to_be_bytes()); + main + }; + let db = Database::open_with_wal(header(0), &[]).unwrap(); + assert!(db.live_tables().unwrap().is_empty()); + assert!(Database::open_with_wal(header(4), &[]).is_ok()); + assert_eq!(Database::open_with_wal(header(5), &[]).err().as_deref(), Some("unsupported SQLite schema format")); +} + +#[test] +fn unsupported_unrelated_schema_entries_are_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let virtual_sql = "CREATE VIRTUAL TABLE unrelated USING fts5(text)"; + let supported_sql = "CREATE TABLE supported (id INTEGER PRIMARY KEY)"; + let virtual_record = schema_record("unrelated", 2, virtual_sql); + let supported_record = schema_record("supported", 2, supported_sql); + leaf_rows(&mut main[..512], 100, &[(1, &virtual_record), (2, &supported_record)]); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].name, "supported"); +} + +#[test] +fn synthetic_btree_substitutes_rowid_and_reads_schema() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let sql = "CREATE TABLE \"t\" (\"id_local\" INTEGER PRIMARY KEY, \"name\" TEXT)"; + // rootpage is an integer field, so replace fourth text value with a compact record. + let mut header = vec![23, 15, 15, 1]; + header.extend(varint((13 + 2 * sql.len()) as u64)); + let mut body = b"tablett".to_vec(); + body.push(2); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.append(&mut header); + record.extend(body); + let schema = record; + leaf(&mut main[512..], 0, 1, &[3, 0, 15, b'x']); + leaf(&mut main[..512], 100, 1, &schema); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert!(tables.iter().any(|table| table.name == "t")); + let rows = db.read_table(2, 2).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); + let scratch = Scratch::new(&db.main); + let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap(); + assert_eq!(file_db.live_tables().unwrap(), tables); + assert_eq!(file_db.read_table(2, 2).unwrap(), rows); + assert_eq!(file_db.main.len(), 100); + file_db.ensure_unchanged().unwrap(); + // The same committed WAL page overrides the lazily read main page. + let mut wal = vec![0; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[32..36].copy_from_slice(&2u32.to_be_bytes()); + wal[36..40].copy_from_slice(&2u32.to_be_bytes()); + leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']); + let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap(); + let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap(); + assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap()); + assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2)); + let mut malformed = db.main.clone(); + malformed.push(0); + let partial = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(), + "SQLite image is not a whole number of pages" + ); + let mut malformed = db.main.clone(); + malformed[24..28].copy_from_slice(&1u32.to_be_bytes()); + malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); + malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); + let oversized = Scratch::new(&malformed); + assert_eq!(Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), "SQLite header page count exceeds image"); +} + +#[test] +fn interior_table_pointer_array_uses_cell_offsets() { + let mut main = vec![0u8; 4 * 512]; + let mut interior = vec![0u8; 512]; + interior[0] = 0x05; + interior[3..5].copy_from_slice(&1u16.to_be_bytes()); + interior[8..12].copy_from_slice(&4u32.to_be_bytes()); + interior[12..14].copy_from_slice(&500u16.to_be_bytes()); + interior[500..504].copy_from_slice(&3u32.to_be_bytes()); + interior[504] = 1; + main[512..1024].copy_from_slice(&interior); + leaf(&mut main[1024..1536], 0, 1, &[2, 0]); + leaf(&mut main[1536..], 0, 2, &[2, 0]); + let db = Database { + file: None, + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 4, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert_eq!(db.read_table(2, 1).unwrap().len(), 2); +} + +#[test] +fn negative_rowid_varint_is_signed() { + let mut main = vec![0u8; 512]; + let page = &mut main[..]; + page[100] = 0x0d; + page[103..105].copy_from_slice(&1u16.to_be_bytes()); + let start = 500usize; + page[start] = 2; + for offset in 1..10 { + if let Some(byte) = page.get_mut(start + offset) { + *byte = 0xff; + } + } + page[start + 10..start + 12].copy_from_slice(&[2, 0]); + page[108..110].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[105..107].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let mut aliases = HashMap::new(); + aliases.insert(1, 0); + let db = Database { + file: None, + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: aliases, + }; + let rows = db.read_table(1, 1).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(-1))); +} + +#[test] +fn reserved_page_trailer_cannot_supply_cells() { + let mut main = vec![0u8; 512]; + main[100] = 0x0d; + main[103..105].copy_from_slice(&1u16.to_be_bytes()); + main[108..110].copy_from_slice(&490u16.to_be_bytes()); + let db = Database { + file: None, + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 32, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.read_table(1, 1).is_err()); + let (_scratch, db) = file_backed(db); + assert!(db.read_table(1, 1).is_err()); +} + +#[test] +fn utf16_and_overflow_payloads_are_supported() { + let mut payload = vec![2, 17, b'A', 0]; + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Le).unwrap(), vec![Value::Text("A".into())]); + payload.clear(); + payload.extend([2, 17, 0, b'A']); + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Be).unwrap(), vec![Value::Text("A".into())]); + + let mut main = vec![0u8; 2 * 512]; + let db = Database { + file: None, + main: std::mem::take(&mut main), + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 2, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + let mut cell_page = vec![b'x'; 512]; + // For a 512-byte table leaf & 600-byte payload, SQLite stores 92 + // payload bytes locally, followed by the overflow page pointer. + cell_page[92..96].copy_from_slice(&2u32.to_be_bytes()); + let inline_page = vec![b'i'; 512]; + assert_eq!(db.payload(&inline_page, 0, 110).unwrap(), vec![b'i'; 110]); + let mut overflow_page = vec![b'y'; 512]; + overflow_page[..4].copy_from_slice(&0u32.to_be_bytes()); + let mut db = db; + db.main[512..].copy_from_slice(&overflow_page); + let mut with_overflow = db.overlay; + with_overflow.insert(2, overflow_page); + db.overlay = with_overflow; + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(bytes.len(), 600); + assert_eq!(&bytes[..92], &[b'x'; 92]); + assert_eq!(&bytes[92..], &[b'y'; 508]); + let (_scratch, mut db) = file_backed(db); + db.overlay.clear(); + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(&bytes[92..], &[b'y'; 508]); +} + +#[test] +fn malformed_varint_and_wal_cycle_fail_cleanly() { + let mut at = 0; + assert!(read_varint(&[0x80; 8], &mut at).is_err()); + let mut wal = vec![0u8; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[16..20].copy_from_slice(&7u32.to_be_bytes()); + wal[20..24].copy_from_slice(&8u32.to_be_bytes()); + wal[32..36].copy_from_slice(&1u32.to_be_bytes()); + wal[36..40].copy_from_slice(&1u32.to_be_bytes()); + wal[40..44].copy_from_slice(&7u32.to_be_bytes()); + wal[44..48].copy_from_slice(&8u32.to_be_bytes()); + let parsed = parse_wal(&wal, 512).unwrap(); + assert_eq!(parsed.frame_count, 1); + assert_eq!(parsed.page_count, Some(1)); + assert_eq!(parsed.pages.get(&1).map(Vec::len), Some(512)); + let mut page = vec![0u8; 512]; + page[16..18].copy_from_slice(&512u16.to_be_bytes()); + let db = Database { + file: None, + main: vec![0; 512], + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.payload(&page, 0, 600).is_err()); +} diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e76ef8169..7377e23f1 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -26,331 +26,23 @@ use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; -const SETTINGS_HASH_ENTRIES: usize = 1024; +mod cache; +mod quick; +mod render; +mod session; +mod settings_hash; +mod source; -#[derive(Default)] -struct SettingsHashes { - entries: HashMap, u64)>, - order: VecDeque, - #[cfg(test)] - computations: usize, -} - -impl SettingsHashes { - fn get(&mut self, id: PhotoId, settings: &Arc) -> u64 { - if let Some((old, hash)) = self.entries.get(&id) - && old.as_ptr() == Arc::as_ptr(settings) - { - return *hash; - } - let hash = settings.hash64(); - #[cfg(test)] - { - self.computations += 1; - } - if !self.entries.contains_key(&id) { - if self.entries.len() >= SETTINGS_HASH_ENTRIES - && let Some(oldest) = self.order.pop_front() - { - self.entries.remove(&oldest); - } - self.order.push_back(id); - } - self.entries.insert(id, (Arc::downgrade(settings), hash)); - hash - } -} - -/// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 70; - -/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). -pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; - -/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the -/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size -/// in between would render (and cache) every face again. -pub const FACE_EDGE_STEP: usize = 16; - -/// The edge a face tile asked to be `edge` pixels across is rendered at: the next -/// [`FACE_EDGE_STEP`], 16..=512. -pub fn face_edge(edge: usize) -> usize { - edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge()) -} - -pub fn thumb_bucket(long_edge: usize) -> usize { - THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512) -} - -/// Memory budgets. -const THUMB_SOURCE_BYTES: usize = 384 << 20; -const RENDERED_MEM_BYTES: usize = 128 << 20; -/// Disk budget for the thumbnail cache. -pub const DISK_CACHE_BYTES: u64 = 2 << 30; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SourceLevel { - Thumb, - Preview, - /// The original at its native resolution (exports larger than a preview). - Full, -} - -impl SourceLevel { - pub fn max_edge(self) -> usize { - match self { - SourceLevel::Thumb => 512, - SourceLevel::Preview => 2560, - SourceLevel::Full => usize::MAX, - } - } - /// Smallest level that can serve an output of `long_edge` pixels. - pub fn for_size(long_edge: usize) -> SourceLevel { - match long_edge { - 0..=520 => SourceLevel::Thumb, - 521..=2560 => SourceLevel::Preview, - _ => SourceLevel::Full, - } - } -} - -/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows -/// only part of the photo, so its pixels come from a source that many times larger (the source -/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview). -fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize { - let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); - let r = settings.crop.geometry.rect; - let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) }; - if !shown.is_finite() || shown < 1.0 { - return out_long; - } - let needed = out_long as f64 * w.max(h) / shown; - if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long } -} - -/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). -pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; - -/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs) -/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export, -/// which must render the full image. -pub type ReducedLoader = Arc Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>; - -/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than -/// `max_edge` (set by the app). `None`: no usable preview. -pub type PreviewLoader = Arc Option + Send + Sync>; - -/// Makes a photo's denoised picture when it is not cached yet (an export does not wait for the background queue): -/// `Ok(true)` once it is there, `Ok(false)` when denoise does not apply to this photo (render it plain), `Err` when it -/// should have worked and did not. -pub type MakeProduct = Arc Result + Send + Sync>; - -/// A raw photo's cached AI-denoised picture (see [`crate::denoise`]): where the file is and the key it must have been -/// made with. A file that is missing, damaged or made for something else is a cache miss, never an error. -#[derive(Clone)] -pub struct DenoiseSpec { - pub product: std::path::PathBuf, - pub key: String, - /// Run before the picture is read, by a job that must not render without it (an export). - pub make: Option, -} - -/// Decodes a raw file once and develops it twice: as usual, and from the denoised picture in `spec` when that can be -/// read (`None` when it cannot). Both are at most `max_edge` long and the same size. -pub type PairLoader = Arc Result<(Rgb32f, Option, SourceInfo), String> + Send + Sync>; - -/// A photo's denoised picture next to its plain one, and the last mix of the two that was asked for. -pub struct Twin { - pub image: Arc, - /// The Amount (in thousandths) and the picture mixed for it, so dragging other sliders keeps one source picture. - mix: std::sync::Mutex)>>, -} - -impl Twin { - pub fn new(image: Arc) -> Twin { - Twin { image, mix: std::sync::Mutex::new(None) } - } - - /// Bytes reserved for the twin and its lazy mix, before either cache cost can grow. - fn bytes(&self) -> usize { - self.image.data.len().saturating_mul(24) - } -} - -/// Pixels and the source interpretation learned while decoding; they must be cached together. -#[derive(Clone)] -pub struct DecodedSource { - pub image: Arc, - pub info: Option, - /// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the - /// catalog's header facts lack (used when `info` is `None`). - pub camera_tone: Option, - /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. - pub denoised: Option>, - /// These pixels came from the photo's smart preview (an offline original), not from its file. - /// A refresh must never rebuild a proxy from itself: that would stamp its older look as current. - pub from_proxy: bool, - /// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image - /// itself: good for a thumbnail, never for an export. - pub reduced: bool, -} - -impl DecodedSource { - pub fn new(image: Arc, info: Option) -> Self { - DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false } - } - - /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, - /// the denoised one, or a mix of the two. A mix is made once per amount and kept, so the stages after it are - /// reused until the amount changes. - pub fn image_for(&self, amount: f32) -> Arc { - let Some(twin) = &self.denoised else { return self.image.clone() }; - if amount.is_nan() || amount <= 0.0 || twin.image.width != self.image.width || twin.image.height != self.image.height { - return self.image.clone(); - } - if amount >= 1.0 { - return twin.image.clone(); - } - let thousandths = (amount * 1000.0).round().clamp(1.0, 999.0) as u32; - let mut memo = twin.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some((t, img)) = memo.as_ref() - && *t == thousandths - { - return img.clone(); - } - let mut mixed = (*self.image).clone(); - if !lightcraft_denoise::product::blend(&mut mixed, &twin.image, thousandths as f32 / 1000.0) { - return self.image.clone(); - } - let mixed = Arc::new(mixed); - *memo = Some((thousandths, mixed.clone())); - mixed - } - - /// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix. - pub fn bytes(&self) -> usize { - let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes()); - let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4); - self.image.data.len() * 12 + 64 + std::mem::size_of::() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes()) - } - - /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) - /// with any stored camera tone curve. The original's size comes from `header` either way: the - /// decoder only sees the level it decoded. - pub fn info_or(&self, header: SourceInfo) -> SourceInfo { - match &self.info { - Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, - None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, - } - } -} - -#[derive(Clone)] -pub enum SourceRef { - Loaded(Box), - /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of - /// the photo open in the loupe is cut from its preview-level source instead of decoding the - /// file again (a second or more for a 64 MP raw). - Fit { - from: Box, - max_edge: usize, - }, - Demo { - scene: Box, - max_edge: usize, - }, - File { - path: String, - max_edge: usize, - loader: Option, - /// The photo's smart preview, used when the original can't be read (offline drive). The - /// UI thread never checks whether the original is there; the render worker finds out. - fallback: Option, - /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). - denoise: Option<(DenoiseSpec, PairLoader)>, - /// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job. - reduced: Option, - }, - /// A smart preview standing in for a missing original. - Smart { - path: std::path::PathBuf, - }, -} - -impl SourceRef { - pub fn load(&self) -> Result, String> { - self.load_source().map(|s| s.image) - } - - pub fn load_source(&self) -> Result { - let image = match self { - SourceRef::Loaded(a) => return Ok((**a).clone()), - SourceRef::Fit { from, max_edge } => { - if from.image.width.max(from.image.height) <= *max_edge { - return Ok((**from).clone()); - } - // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near - // the output's resolution limit and aliases what is finer - let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); - return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); - } - SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, - SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => { - let plain = || match (reduced, loader) { - (Some(l), _) => { - l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) }) - } - (None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), - (None, None) => Err(format!("no decoder available for {path}")), - }; - // the denoised picture comes with the plain one from a single decode; when it cannot be read the - // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) - let r = match denoise { - Some((spec, pair)) => { - // a job that needs the denoised picture has it made first; if that fails the job fails (no smart - // preview stands in: an export must not quietly come out without the denoise it was asked for) - let made = match &spec.make { - Some(make) => make()?, - None => true, - }; - if made { - pair(path, *max_edge, spec).and_then(|(image, twin, info)| { - if spec.make.is_some() && twin.is_none() { - return Err("the cached AI Denoise picture could not be read; clear the denoise cache and retry".into()); - } - Ok(DecodedSource { - image: Arc::new(image), - info: Some(info), - camera_tone: None, - denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), - from_proxy: false, - reduced: false, - }) - }) - } else { - plain() - } - } - None => plain(), - }; - // An offline original renders from its smart preview (no decoder facts: header ones). - return match (r, fallback) { - #[cfg(not(target_arch = "wasm32"))] - (Err(e), Some(sp)) if denoise.as_ref().is_none_or(|(spec, _)| spec.make.is_none()) && crate::smart::is_valid(sp) => { - crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")) - } - (r, _) => r, - }; - } - #[cfg(not(target_arch = "wasm32"))] - SourceRef::Smart { path } => return crate::smart::load(path), - #[cfg(target_arch = "wasm32")] - SourceRef::Smart { .. } => return Err("smart previews are not available here".into()), - }; - Ok(DecodedSource::new(image, None)) - } -} +pub use cache::DenoiseIndex; +pub use quick::{QuickJob, QuickSource, content_key, source_info}; +pub use render::{RenderJob, RenderResult, develop, develop_reporting, fit_for_display}; +#[cfg(test)] +use settings_hash::SETTINGS_HASH_ENTRIES; +use settings_hash::SettingsHashes; +pub use source::{ + DISK_CACHE_BYTES, DecodedSource, DenoiseSpec, FACE_EDGE_STEP, FileLoader, FileProbe, MakeProduct, PairLoader, PreviewLoader, ProbeInfo, + RENDER_CACHE_VERSION, ReducedLoader, SourceLevel, SourceRef, THUMB_SIZES, Twin, face_edge, thumb_bucket, +}; pub struct MediaCache { settings_hashes: SettingsHashes, @@ -389,2010 +81,8 @@ pub struct MediaCache { pub denoise: DenoiseIndex, } -/// 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. -#[derive(Default)] -pub struct DenoiseIndex { - ready: std::collections::HashMap, - /// Reads the pictures (`None`: this build cannot, and nothing is ever ready). - pub loader: Option, -} - -impl DenoiseIndex { - /// What to load for `id` besides its plain picture, and how. - pub fn get(&self, id: PhotoId) -> Option<(&DenoiseSpec, &PairLoader)> { - self.ready.get(&id).zip(self.loader.as_ref()) - } - - pub fn spec(&self, id: PhotoId) -> Option<&DenoiseSpec> { - self.ready.get(&id) - } - - pub fn set(&mut self, id: PhotoId, spec: DenoiseSpec) { - self.ready.insert(id, spec); - } - - pub fn remove(&mut self, id: PhotoId) -> bool { - self.ready.remove(&id).is_some() - } - - pub fn clear(&mut self) { - self.ready.clear(); - } - - pub fn len(&self) -> usize { - self.ready.len() - } - - pub fn is_empty(&self) -> bool { - self.ready.is_empty() - } - - pub fn ids(&self) -> Vec { - let mut v: Vec = self.ready.keys().copied().collect(); - v.sort(); - v - } -} - -impl Default for MediaCache { - fn default() -> Self { - let budget = crate::memory::cache_share(crate::memory::budget()); - MediaCache { - settings_hashes: SettingsHashes::default(), - thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), - previews: Vec::new(), - full: Vec::new(), - full_capacity: 1, - on_screen: None, - preview_capacity: 0, - budget, - file_loader: None, - reduced_loader: None, - file_probe: None, - preview_loader: None, - file_bytes: None, - smart_dir: None, - availability: Default::default(), - scenes: Vec::new(), - rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), - denoise: DenoiseIndex::default(), - } - } -} - -/// Memory for rendered previews out of the cache share. -fn rendered_budget(share: usize) -> usize { - RENDERED_MEM_BYTES.min(share / 4) -} - -impl MediaCache { - /// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired - /// (its files stay): render jobs still holding it can't write through it any more, even - /// into a directory a later clear emptied (`dir` may be the same one). - pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { - self.rendered.retire(); - self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes)); - } - - /// Change the disk budget of the cache in `dir`: in place when that cache is attached (its - /// thumbnails are keyed by content, so they and the textures shown from them stay valid, - /// and the same cache object keeps its generation), else attach it. - pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) { - match self.rendered.disk().filter(|d| d.dir() == dir) { - Some(d) => d.set_budget(disk_bytes), - None => self.attach_disk_cache(dir, disk_bytes), - } - } - - /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). - pub fn clear_sources(&mut self) { - self.settings_hashes = SettingsHashes::default(); - self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); - self.previews.clear(); - self.full.clear(); - } - - /// How many full-resolution originals to keep at once (at least one). Compare and Reference - /// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window - /// render of one side would evict the other side's original and decode its own again. Lowering - /// it drops the least recently used originals at once. - pub fn set_full_capacity(&mut self, n: usize) { - self.full_capacity = n.max(1); - self.trim_full(); - } - - /// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays - /// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it - /// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once. - pub fn set_on_screen(&mut self, id: Option) { - if self.on_screen != id { - self.on_screen = id; - self.enforce_budget(); - } - } - - fn trim_full(&mut self) { - while self.full.len() > self.full_capacity { - let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); - self.full.remove(oldest); - } - } - - /// Bytes the caches may hold together. - pub fn budget(&self) -> usize { - self.budget - } - - /// Change the bytes the caches may hold together, evicting at once if needed. - pub fn set_budget(&mut self, bytes: usize) { - self.budget = bytes; - self.thumbs.set_budget(THUMB_SOURCE_BYTES.min(bytes)); - self.rendered.set_mem_budget(rendered_budget(bytes)); - self.enforce_budget(); - } - - /// Bytes held by decoded sources and rendered previews in memory. - pub fn held(&self) -> usize { - let (t, p, f) = self.usage(); - t.bytes + p.bytes + f.bytes + self.rendered.mem_usage().1 - } - - /// Evict least recently used entries across the source caches and the rendered previews - /// until they fit the budget. The newest preview-level source (the photo on screen, or the - /// one just decoded) is never evicted here, nor is the original of the photo on screen - /// ([`Self::set_on_screen`]). - pub fn enforce_budget(&mut self) { - // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) - // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and - // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. - let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); - let mut held = self.held().saturating_sub(pinned); - while held > self.budget { - let newest_preview = self.previews.iter().map(|e| e.2).max(); - let candidates = [ - self.thumbs.oldest_tick().map(|t| (t, 0)), - self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), - self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)), - self.rendered.oldest_tick().map(|t| (t, 3)), - ]; - let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; - let freed = match which { - 0 => self.thumbs.pop_oldest(), - 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()), - 2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()), - _ => self.rendered.evict_oldest(), - }; - match freed { - Some(b) if b > 0 => held = held.saturating_sub(b), - _ => break, - } - } - } - - pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { - self.get_source(id, level).map(|s| s.image) - } - - fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option { - match level { - SourceLevel::Thumb => self.thumbs.get(&id).cloned(), - SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| { - e.2 = lightcraft_preview::next_tick(); - e.1.clone() - }), - SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| { - e.2 = lightcraft_preview::next_tick(); - e.1.clone() - }), - } - } - - /// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header` - /// (with a smart preview's stored camera tone curve). - fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo { - let cached = || { - self.thumbs - .peek(&id) - .into_iter() - .chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1)) - .chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1)) - }; - match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) { - Some(s) => s.info_or(header), - None => header, - } - } - - pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { - self.insert_source(id, level, DecodedSource::new(img, None)); - } - - fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) { - let tick = lightcraft_preview::next_tick(); - match level { - SourceLevel::Thumb => { - let cost = img.bytes(); - self.thumbs.insert(id, img, cost); - } - SourceLevel::Preview => { - self.previews.retain(|e| e.0 != id); - self.previews.push((id, img, tick)); - let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; - while self.previews.len() > cap { - let oldest = self.previews.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); - self.previews.remove(oldest); - } - } - SourceLevel::Full => { - self.full.retain(|e| e.0 != id); - self.full.push((id, img, tick)); - self.trim_full(); - } - } - self.enforce_budget(); - } - - /// Forget a photo's decoded sources (e.g. after its file changed). - pub fn forget(&mut self, id: PhotoId) { - self.thumbs.remove(&id); - self.previews.retain(|e| e.0 != id); - self.full.retain(|e| e.0 != id); - } - - /// (decoded thumbnail sources, bytes). - pub fn source_usage(&self) -> (usize, usize) { - (self.thumbs.len(), self.thumbs.cost()) - } - - /// Whether the decoded source of `id` at `level` is held. - pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool { - match level { - SourceLevel::Thumb => self.thumbs.contains(&id), - SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), - SourceLevel::Full => self.full.iter().any(|e| e.0 == id), - } - } - - /// Decoded sources held: (thumbnail level, preview level, full size). - pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { - use crate::memory::Usage; - let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum()); - let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum()); - (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) - } - - /// What a render of photo `id` with settings `s` adds to its identity for the denoised picture it will use: 0 unless - /// it uses one (the Denoise amount is above 0 and the photo has a cached denoised picture). - pub fn denoise_salt(&self, id: PhotoId, s: &DevelopSettings) -> u64 { - match self.denoise.spec(id) { - Some(spec) if s.denoise_amount() > 0.0 => Hasher128::new().str(&spec.key).finish().0 as u64 | 1, - _ => 0, - } - } - - /// How to load photo `p` at `level`: from the cache when its source is there, else from its file (with its denoised - /// picture beside it when it has one), its smart preview or the demo scene. - pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { - self.source_ref_with(p, level, true) - } - - /// [`Self::source_ref`] that never takes a decoded source from memory: for a job that needs something the cached - /// one lacks (an export, whose denoised picture is made when it runs). - pub fn source_ref_uncached(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { - self.source_ref_with(p, level, false) - } - - fn source_ref_with(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { - let mut r = self.source_ref_plain(p, level, cached); - if let (SourceRef::File { denoise, .. }, Some((spec, loader))) = (&mut r, self.denoise.get(p.id)) { - *denoise = Some((spec.clone(), loader.clone())); - } - r - } - - fn source_ref_plain(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { - if cached && let Some(a) = self.get_source(p.id, level) { - return SourceRef::Loaded(Box::new(a)); - } - // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not - // with a denoised picture, which the thumbnail would lose - if cached - && level == SourceLevel::Thumb - && self.denoise.spec(p.id).is_none() - && let Some(big) = self.get_source(p.id, SourceLevel::Preview) - { - return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; - } - // Procedural scenes have a nominal size: "full" is that size, not unbounded. - let max_edge = level.max_edge().min(match (&p.source, level) { - (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, - _ => usize::MAX, - }); - // the original is offline: its smart preview, when there is one. Only cached answers are - // used here (no file-system call on the UI thread in the app); an original not known to - // be offline is tried first and its smart preview is the render worker's fallback. - #[cfg(not(target_arch = "wasm32"))] - if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) { - let sharded = crate::smart::sharded_file_path(dir, p); - let legacy = dir.join(crate::smart::file_name(p)); - let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy }; - // (a proxy cut short fails to load, like the offline original; the worker's fallback - // in `SourceRef::load` only uses a proxy that passes `smart::is_valid`) - if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) { - return SourceRef::Smart { path: sp }; - } - let mut r = self.origin_ref(&p.source, max_edge); - if let SourceRef::File { fallback, .. } = &mut r { - *fallback = Some(sp); - } - return r; - } - self.origin_ref(&p.source, max_edge) - } - - /// How to load `origin` at most `max_edge` pixels long, ignoring decoded sources in memory (a - /// render worker in another wasm instance builds its sources from this). - pub fn origin_ref(&mut self, origin: &Source, max_edge: usize) -> SourceRef { - match origin { - Source::Demo { scene } => { - if self.scenes.is_empty() { - self.scenes = lightcraft_scenes::demo_library(); - } - match self.scenes.iter().find(|s| s.id == *scene) { - Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, - None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None }, - } - } - Source::File { path } => { - SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None } - } - } - } -} - -/// Everything needed to render one photo, detached from the session. -#[derive(Clone)] -pub struct RenderJob { - /// Assigned by a frontend to distinguish late completions, even for an identical render key. - pub request_id: u64, - pub cache_generation: u64, - /// Content identity for validating a decoded source returned after a reload. - pub source_key: Option, - pub photo: PhotoId, - pub level: SourceLevel, - pub source: SourceRef, - /// Where the photo's pixels come from (for executors that can't share `source`, e.g. a web - /// worker). - pub origin: Source, - pub info: SourceInfo, - pub settings: Arc, - pub request: RenderRequest, - /// Identifies the result for caching: hash of settings + request. - pub key: u64, - /// Rendered-thumbnail cache and this job's key in it. - pub cache: Option<(Arc, Hash128)>, - /// Intermediate results of this view's previous renders (set by the frontend for the loupe): - /// slider drags then only redo the stages the changed setting feeds. - pub stages: Option>, - /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). - pub view_cache: Option<(Arc, Hash128)>, - /// Render for this monitor profile ([`Self::with_display`]). - pub display: Option>, - /// Compose the render into a slideshow slide ([`Self::with_slide`]). - pub slide: Option>, - /// A face tile that can be cut from the raw's embedded preview, tried before `source` - /// ([`crate::Session::face_job`]; `None` for every other job). - pub preview_cut: Option, - /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once - /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up - /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at - /// small ratios and aliases it at large ones. The caches keep the full render. - pub display_fit: Option<(usize, usize)>, -} - -/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). -pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { - use lightcraft_raster::resample::{Filter, fit}; - let f = lightcraft_raster::Image { - width: img.width, - height: img.height, - data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), - }; - let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); - let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; - Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } -} - -pub struct RenderResult { - pub request_id: u64, - pub source_key: Option, - pub photo: PhotoId, - pub level: SourceLevel, - pub key: u64, - pub rendered: Result, - /// Set when this job's render was tried on the GPU and fell back to the CPU: why - /// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way. - pub gpu_fallback: Option, - /// A source that was loaded by this job (to be inserted into the cache). - pub loaded: Option, - /// Set for a [`QuickJob`]'s stand-in: where the image came from. - pub quick: Option, - /// The display profile (id) the image is encoded for; `None`: sRGB (see - /// [`crate::display::present`]). - pub display: Option, -} - -impl RenderJob { - /// Render at draft quality (interactive drags). Gets its own result key. - /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see - /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. - pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { - self.display_fit = Some((w, h)); - self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); - self - } - - pub fn draft(mut self) -> Self { - if self.request.quality != Quality::Draft { - self.request.quality = Quality::Draft; - self.key ^= 0x9e37_79b9_7f4a_7c15; - } - self - } - - /// Draw a diagnostic overlay over the result (e.g. Point Color's visualized range). Gets its - /// own result key. - pub fn with_overlay(mut self, overlay: lightcraft_pipeline::Overlay) -> Self { - if self.request.overlay != overlay { - self.key ^= self.request.overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); - self.request.overlay = overlay; - self.key ^= overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); - } - // a diagnostic view (visualized range / spots) must never become the photo's cached preview - if overlay != lightcraft_pipeline::Overlay::None { - self.view_cache = None; - } - self - } - - /// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is - /// never kept as the photo's cached preview. - pub fn with_proof(mut self, proof: Option) -> Self { - if self.request.proof != proof { - let k = |p: Option| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f); - self.key ^= k(self.request.proof) ^ k(proof); - self.request.proof = proof; - } - if proof.is_some() { - self.view_cache = None; - } - self - } - - /// Render with some colour grading wheels (shadows, midtones, highlights) switched off for - /// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result - /// gets its own key and is never cached as the photo's preview or thumbnail. - pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self { - if !hidden.iter().any(|h| *h) { - return self; - } - let mut s = (*self.settings).clone(); - let g = &mut s.grading; - for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) { - if off { - *wheel = lightcraft_develop::Wheel::default(); - } - } - let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i)); - self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93); - self.settings = Arc::new(s); - self.cache = None; - self.view_cache = None; - self.preview_cut = None; - self - } - - /// Reuse `stages` across this view's renders. - pub fn with_stages(mut self, stages: Arc) -> Self { - self.stages = Some(stages); - self - } - - /// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready - /// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB. - /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: - /// [`crate::display::present`] converts their sRGB results. - pub fn with_display(mut self, display: Option>) -> Self { - // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) - if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { - return self; - } - let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); - self.key ^= k(&self.display) ^ k(&display); - self.request.display = display.as_ref().map(|d| d.space); - self.display = display; - self - } - - /// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting - /// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace - /// it, so a few such panics used to stop every render). - pub fn run(self) -> RenderResult { - let failed = self.failed(String::new()); - crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed }) - } - - /// This job's result when it fails with `e`. - fn failed(&self, e: String) -> RenderResult { - RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Err(e), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - } - } - - /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the - /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the - /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). - pub fn with_slide(mut self, slide: Arc) -> Self { - let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); - self.key ^= k(&self.slide); - self.key ^= k(&Some(slide.clone())); - self.slide = Some(slide); - self - } - - /// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard). - fn run_unguarded(self) -> RenderResult { - let slide = self.slide.clone(); - let mut r = self.run_photo(); - if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { - rendered.image = slide.compose(&rendered.image); - rendered.deep = None; - } - r - } - - /// The photo's render (without slide composition). - fn run_photo(self) -> RenderResult { - if let Some((cache, key)) = &self.cache - && let Some(img) = cache.get_at(self.cache_generation, *key) - { - let image = Arc::unwrap_or_clone(img); - let histogram = Histogram::of_srgb8(&image); - return RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Ok(Rendered { image, histogram, deep: None }), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }; - } - // a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough - // (the sensor data is never read for it), else rendered from `source` below - if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) { - if let Some((cache, key)) = &self.cache { - cache.put_at(self.cache_generation, *key, Arc::new(image.clone())); - } - let histogram = Histogram::of_srgb8(&image); - return RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Ok(Rendered { image, histogram, deep: None }), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }; - } - let was_loaded = matches!(self.source, SourceRef::Loaded(_)); - // The app is closing (issue #620): nobody is left to show this, and quitting waits for the - // jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU. - let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string()); - let source = match closing() { - Some(e) => Err(e), - None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)), - }; - match source { - Ok(source) => { - // the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for - let src = &source.image_for(self.settings.denoise_amount()); - let info = source.info_or(self.info); - // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use - // the GPU when there is one. - let gpu = self.cache.is_none(); - let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); - // for a display profile: the image goes to the display's values, the histogram - // and the cached view preview stay sRGB - let mut srgb = None; - let display = match (&self.display, self.request.display) { - (Some(d), Some(_)) => { - let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; - let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); - // (an HDR edit's HDR histogram stays) - if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) { - rendered.histogram = h; - } - srgb = s; - Some(d.id()) - } - _ => None, - }; - if let Some((cache, key)) = &self.cache { - cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); - } - if let Some((cache, key)) = &self.view_cache - && self.request.quality == Quality::Full - && (display.is_none() || srgb.is_some()) - { - cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); - } - // the display fit, after the caches have the full render - if let Some((fw, fh)) = self.display_fit - && self.request.quality == Quality::Full - { - let (w, h) = (rendered.image.width, rendered.image.height); - if w > fw.max(1) || h > fh.max(1) { - rendered.image = fit_for_display(&rendered.image, fw, fh); - } - } - RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Ok(rendered), - gpu_fallback, - loaded: (!was_loaded).then_some(source), - quick: None, - display, - } - } - Err(e) => self.failed(e), - } - } -} - -/// Render `src`: on the GPU when `gpu` is set and a GPU is available (`lightcraft_gpu`), else on -/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see -/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. -pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { - develop_reporting(src, info, s, req, stages, gpu).0 -} - -/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`: -/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the -/// process-wide [`lightcraft_gpu::last_fallback`]. -pub fn develop_reporting( - src: &Arc, - info: &SourceInfo, - s: &DevelopSettings, - req: &RenderRequest, - stages: Option<&StageCache>, - gpu: bool, -) -> (Rendered, Option) { - let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu); - // an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead - if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) { - rendered.histogram = h; - } - (rendered, fallback) -} - -fn develop_image( - src: &Arc, - info: &SourceInfo, - s: &DevelopSettings, - req: &RenderRequest, - stages: Option<&StageCache>, - gpu: bool, -) -> (Rendered, Option) { - // LUT profiles have no GPU stage: they render on the CPU - let fallback = if gpu - && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) - && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) - && req.proof.is_none() - { - match lightcraft_gpu::try_render(src, info, s, req, stages) { - Ok(r) => return (r, None), - Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why), - Err(lightcraft_gpu::Fallback::NotTried) => None, - } - } else { - None - }; - let rendered = match stages { - Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st), - None => lightcraft_pipeline::render(src, info, s, req), - }; - (rendered, fallback) -} - -/// Where a [`QuickJob`]'s stand-in image came from. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum QuickSource { - /// The photo's own develop render for its current settings (cached view or thumbnail render). - Cached, - /// The camera's embedded JPEG (unedited raws). - Embedded, - /// A thumbnail-level develop render made for the occasion. - Small, -} - -/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached -/// renders, the embedded preview of an unedited raw, a thumbnail-level render. -#[derive(Clone)] -pub struct QuickJob { - pub request_id: u64, - pub photo: PhotoId, - pub key: u64, - /// Cached renders to try first, in order: (cache, key). - pub cached: Vec<(Arc, Hash128)>, - /// Embedded preview of an unedited raw: (path, loader, max edge). - pub embedded: Option<(String, PreviewLoader, usize)>, - /// Last resort: run this thumbnail job. - pub small: Option>, -} - -impl QuickJob { - /// The stand-in, from the first source that has one. A panic inside is this job's failure - /// (see [`RenderJob::run`]). - pub fn run(self) -> RenderResult { - let (request_id, photo, key) = (self.request_id, self.photo, self.key); - crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { - request_id, - source_key: None, - photo, - level: SourceLevel::Thumb, - key, - rendered: Err(e), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }) - } - - fn run_unguarded(self) -> RenderResult { - let (photo, key) = (self.photo, self.key); - let request_id = self.request_id; - let done = |rendered: Result, quick| RenderResult { - request_id, - source_key: None, - photo, - level: SourceLevel::Thumb, - key, - rendered, - gpu_fallback: None, - loaded: None, - quick, - display: None, - }; - let rendered = |image: Rgba8| { - let histogram = Histogram::of_srgb8(&image); - Ok(Rendered { image, histogram, deep: None }) - }; - for (cache, k) in &self.cached { - if let Some(img) = cache.get(*k) { - return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached)); - } - } - if let Some((path, loader, edge)) = &self.embedded - && let Some(image) = loader(path, *edge) - { - return done(rendered(image), Some(QuickSource::Embedded)); - } - if let Some(job) = self.small { - let r = job.run(); - return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r }; - } - done(Err("no quick preview".into()), None) - } -} - -/// What identifies a photo's pixels for caching: its content hash, else its source. -pub fn content_key(p: &Photo) -> String { - match (&p.content_hash, &p.source) { - (Some(h), _) => h.clone(), - (None, Source::Demo { scene }) => format!("demo:{scene}"), - (None, Source::File { path }) => format!("file:{path}:{}", p.file_size), - } -} - -pub fn source_info(p: &Photo) -> SourceInfo { - // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. - // (They are drawn afresh at every size, so they have no original size.) - if matches!(p.source, Source::Demo { .. }) { - return SourceInfo { raw: true, ..Default::default() }; - } - // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) - let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); - SourceInfo { native_long, ..header_info(p) } -} - -fn header_info(p: &Photo) -> SourceInfo { - // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white - // balance and the display tone curve, like any other rendered file. - if p.develops_raw() { - let relative = p.relative_wb(); - let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; - SourceInfo { - raw: true, - as_shot_temp: temp, - as_shot_tint: tint, - lens: p.embedded_lens, - relative_wb: relative, - // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`) - legacy_relative_wb: p.fitted_look() && !relative, - ..Default::default() - } - } else { - SourceInfo::default() - } -} - -impl crate::Session { - /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. - pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option { - self.build_job(id, max_w, max_h, before, apply_crop, None) - } - - /// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`] - /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). - pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { - let b = thumb_bucket(long_edge); - let mut job = self.build_job(id, b, b, false, true, Some(b))?; - // (a thumbnail may develop a smaller copy of the raw image that the file keeps) - if let SourceRef::File { reduced, .. } = &mut job.source { - reduced.clone_from(&self.media.reduced_loader); - } - Some(job) - } - - fn build_job( - &mut self, - id: PhotoId, - max_w: usize, - max_h: usize, - before: bool, - apply_crop: bool, - thumb_bucket: Option, - ) -> Option { - let p = self.catalog.photo(id)?.clone(); - let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() }; - let level = SourceLevel::for_size(if thumb_bucket.is_some() { - max_w.max(max_h) // (grid thumbnails of tight crops stay cheap) - } else { - source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h)) - }); - let source = self.media.source_ref(&p, level); - let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; - // the photo id is part of the key: two photos with the same settings and size must not - // share a result (a view slot showing photo A would otherwise look current for photo B) - // …and so is the file's content: a file changed on disk (Reload) renders afresh - let content_key = content_key(&p); - let source_key = Hasher128::new().str(&content_key).finish(); - let content = source_key.0 as u64; - // Temporary "before" settings must not replace the photo's cached develop hash. - let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) }; - let salt = self.media.denoise_salt(id, &settings); - let key = settings_hash - ^ ((max_w as u64) << 40) - ^ ((max_h as u64) << 20) - ^ (apply_crop as u64) - ^ (level as u64) << 60 - ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) - ^ content.rotate_left(17) - ^ salt; - let cache = thumb_bucket.map(|b| { - let mut k = Hasher128::new(); - k.str(&content_key).u64(settings_hash).u64(b as u64).u64(RENDER_CACHE_VERSION).u64(crate::camera_profiles::cache_key()); - // (only a render that uses a denoised picture has it in its key, so every other thumbnail stays valid) - if salt != 0 { - k.u64(salt); - } - (self.media.rendered.clone(), k.finish()) - }); - Some(RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: Some(source_key), - photo: id, - level, - source, - origin: p.source.clone(), - info: source_info(&p), - settings, - request, - key, - cache, - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut: None, - display_fit: None, - }) - } - - /// A render of `id` with `settings` in place of its own, fitting `max_w × max_h` (the loupe's - /// temporary preview while hovering a preset or profile; nothing is committed or cached). - pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option { - let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; - job.settings = Arc::new(settings.clone()); - job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60; - Some(job) - } - - /// Cache key of a variant thumbnail ([`Self::variant_job`]). - pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { - Hasher128::new() - .str(&content_key(p)) - .str("variant") - .u64(settings.hash64()) - .u64(edge as u64) - .u64(RENDER_CACHE_VERSION) - .u64(crate::camera_profiles::cache_key()) - .finish() - } - - /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset - /// browsers): from the thumbnail-level source, long edge `edge` (≤ 512), cropped. The result - /// is cached (memory + the library's disk cache) under the photo's content and the settings - /// hash, so a variant renders once; `key` is derived from the same hash, so a frontend can - /// keep one texture per variant. - pub fn variant_job(&mut self, id: PhotoId, settings: &DevelopSettings, edge: usize) -> Option { - let p = self.catalog.photo(id)?.clone(); - let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); - let level = SourceLevel::Thumb; - let source = self.media.source_ref(&p, level); - let ck = Self::variant_key(&p, settings, edge); - Some(RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), - photo: id, - level, - source, - origin: p.source.clone(), - info: source_info(&p), - settings: Arc::new(settings.clone()), - request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, - key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), - cache: Some((self.media.rendered.clone(), ck)), - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut: None, - display_fit: None, - }) - } - - /// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the - /// photo's own look with its crop replaced by a square around the face (room for hair and chin), - /// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size - /// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's - /// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the - /// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source - /// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the - /// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows - /// the content: a later launch shows the tile without reading the original. - pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { - let p = self.catalog.photo(id)?.clone(); - // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the - // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. - let orient = p.develop.orientation; - let face = orient.map_norm_rect(face); - let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); - let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; - let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; - // finite, within the photo, and never wider than its short side (so the clamps below are valid) - let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; - let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h); - let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 }); - let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0); - let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h }; - let mut settings = (*p.develop).clone(); - settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() }; - let edge = face_edge(edge); - // pixels along the long edge that leave `edge` across the crop - let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64); - let level = SourceLevel::for_size(needed as usize); - let source = self.media.source_ref(&p, level); - let ck = Self::variant_key(&p, &settings, edge); - let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize); - Some(RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), - photo: id, - level, - source, - origin: p.source.clone(), - info: source_info(&p), - settings: Arc::new(settings), - request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, - key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), - cache: Some((self.media.rendered.clone(), ck)), - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut, - display_fit: None, - }) - } - - /// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an - /// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as - /// imported, with a preview loader installed. `rect` is the square around the face, `edge` the - /// tile, `needed` the source's long edge the develop render would ask for. - fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option { - let (path, loader) = self.embedded_of(p)?; - if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0 - { - return None; - } - let want = needed.max(edge).max(1); - let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize; - let aspect = f64::from(p.width) / f64::from(p.height); - Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge }) - } - - /// Size-independent cache key of a photo's view render (loupe) for its current settings. - fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { - Hasher128::new() - .str(&content_key(p)) - .str("view") - .u64(p.develop.hash64()) - .u64(apply_crop as u64) - .u64(RENDER_CACHE_VERSION) - .u64(crate::camera_profiles::cache_key()) - .finish() - } - - /// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the - /// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`]. - pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option { - let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; - let p = self.catalog.photo(id)?; - job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop))); - Some(job) - } - - /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame - /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and - /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. - /// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no - /// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold. - pub fn region_job( - &mut self, - id: PhotoId, - full_w: usize, - full_h: usize, - window: lightcraft_pipeline::PixelWindow, - apply_crop: bool, - ) -> Option { - self.region_job_of(id, full_w, full_h, window, apply_crop, false) - } - - /// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side. - pub fn region_job_before( - &mut self, - id: PhotoId, - full_w: usize, - full_h: usize, - window: lightcraft_pipeline::PixelWindow, - apply_crop: bool, - ) -> Option { - self.region_job_of(id, full_w, full_h, window, apply_crop, true) - } - - fn region_job_of( - &mut self, - id: PhotoId, - full_w: usize, - full_h: usize, - window: lightcraft_pipeline::PixelWindow, - apply_crop: bool, - before: bool, - ) -> Option { - let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?; - // what the window's spots and Auto Mask strokes read must fit in one render: else the - // caller keeps the whole-frame render (a window alone would come out wrong) - let p = self.catalog.photo(id)?; - let frame = lightcraft_pipeline::geometry::Frame::with_lens( - p.width.max(1) as usize, - p.height.max(1) as usize, - &job.settings, - apply_crop, - p.embedded_lens.as_ref(), - ); - let ppl = frame.px_per_long(full_w); - lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?; - job.request.window = Some(window); - job.key = Hasher128::new() - .u64(job.key) - .str("window") - .u64(window.x as u64) - .u64(window.y as u64) - .u64(window.w as u64) - .u64(window.h as u64) - .finish() - .0 as u64; - Some(job) - } - - /// The embedded preview of an unedited raw (path, loader), when the app installed a loader. - fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { - match (&p.source, &self.media.preview_loader) { - (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())), - _ => None, - } - } - - /// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor - /// decode behind it. - /// - /// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws - /// likewise start from the embedded preview"), and then paid for a render of the raw to replace - /// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px - /// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is - /// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind - /// it delayed the render the user was waiting for. - /// - /// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what - /// says the photo still looks the way it did at import. A photo the user edits re-renders - /// through the ordinary path, because its thumbnail key (settings hash) changes. - pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool { - self.embedded_of(p).is_some() - } - - /// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the - /// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the - /// raw. `None` for anything else, and where the host installed no loader. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn scan_preview_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { - match (&p.source, &self.media.preview_loader) { - (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw => Some((path.clone(), l.clone())), - _ => None, - } - } - - /// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view - /// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail. - pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option { - let p = self.catalog.photo(id)?.clone(); - let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?; - // (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview) - let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))]; - let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish(); - let key = h.0 as u64; - let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge()))); - Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) - } - - /// A first grid thumbnail for an unedited raw: the cached render for `job` (the real - /// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries - /// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job` - /// itself: the grid queues no render behind an unedited raw's quick thumbnail - /// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the - /// cell empty (issue #1448). `None` for other photos (render them directly). - pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option { - let p = self.catalog.photo(job.photo)?.clone(); - let (path, l) = self.embedded_of(&p)?; - let edge = job.request.max_w.max(job.request.max_h); - Some(QuickJob { - request_id: 0, - photo: job.photo, - key: job.key, - cached: job.cache.clone().into_iter().collect(), - embedded: Some((path, l, edge)), - small: Some(Box::new(job.clone())), - }) - } - - /// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog - /// has wrong is corrected too ([`Self::learn_as_shot_wb`]). - pub fn accept(&mut self, r: &RenderResult) { - if let Some(src) = &r.loaded - && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) - { - if let Some(info) = &src.info { - self.learn_as_shot_wb(r.photo, info); - } - self.media.insert_source(r.photo, r.level, src.clone()); - } - } - - /// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another: - /// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's - /// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not - /// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot - /// says and what a smart preview renders with. Only for raws edited in Kelvin. - fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { - let Some(p) = self.catalog.photo(id) else { return }; - if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { - return; - } - let read = (info.as_shot_temp, info.as_shot_tint); - if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) { - return; - } - if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) { - log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0); - } - } - - /// Synchronous render (CLI, MCP, tests). - pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { - let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; - let r = job.run(); - self.accept(&r); - r.rendered - } - - /// Synchronous render for export: like [`Self::render_now`], into colour space `space` with - /// sample format `depth`. - pub fn render_export( - &mut self, - id: PhotoId, - max_w: usize, - max_h: usize, - space: lightcraft_pipeline::OutputSpace, - depth: lightcraft_pipeline::OutputDepth, - ) -> Result { - let r = self.export_job(id, max_w, max_h, space, depth)?.run(); - self.accept(&r); - r.rendered - } - - /// The job [`Self::render_export`] runs, to run elsewhere (e.g. a background export thread). - pub fn export_job( - &mut self, - id: PhotoId, - max_w: usize, - max_h: usize, - space: lightcraft_pipeline::OutputSpace, - depth: lightcraft_pipeline::OutputDepth, - ) -> Result { - let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; - // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw - // image that the file keeps (Lightroom's merged DNGs) - let reduced = match &job.source { - SourceRef::Loaded(s) => s.reduced, - SourceRef::Fit { from, .. } => from.reduced, - _ => false, - }; - if reduced { - let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); - job.source = self.media.source_ref_uncached(&p, job.level); - } - // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the - // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) - if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { - let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); - job.source = self.media.source_ref_uncached(&p, job.level); - if let SourceRef::File { denoise, .. } = &mut job.source { - *denoise = Some(wanted); - } - } - job.request.space = space; - job.request.depth = depth; - job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); - Ok(job) - } - - /// Prefer decoder facts to header-only metadata for pixel-statistics commands. - pub fn source_info(&self, id: PhotoId) -> SourceInfo { - let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default(); - self.media.source_facts(id, header) - } - - /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. - pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result, String> { - let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); - let r = self.media.source_ref(&p, level).load_source()?; - let image = r.image.clone(); - if let Some(info) = &r.info { - self.learn_as_shot_wb(id, info); - } - self.media.insert_source(id, level, r); - Ok(image) - } -} - -/// What an import learns from a file header (set by the app from `lightcraft-codecs`/`-raw`). -#[derive(Clone, Debug, Default)] -pub struct ProbeInfo { - pub width: u32, - pub height: u32, - pub format: String, - pub kind: MediaKind, - pub file_size: u64, - pub captured: Option, - /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the - /// capture date of a file whose metadata has none, as Lightroom does (exports saved without - /// metadata would otherwise all land under "Unknown Date"). - pub file_modified: Option, - pub meta: lightcraft_catalog::Meta, - pub as_shot_wb: Option<(f64, f64)>, - /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit - /// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it), - /// Import → Copy verifies each copy against it instead of reading the source again. - pub content_hash: Option, - /// Lens corrections embedded in the file (DNG opcodes). - pub embedded_lens: Option, - /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file. - pub xmp: Option, - /// A raw variant that can't be decoded yet: why. The file is described (and will be shown and - /// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`]. - pub preview_only: Option, -} - -pub type FileProbe = Arc Result + Send + Sync>; +#[cfg(test)] +mod thumbnail_hash_tests; #[cfg(test)] -mod thumbnail_hash_tests { - use super::*; - - fn session() -> crate::Session { - let mut s = crate::Session::new(); - let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01"); - s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); - s - } - #[test] - fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() { - let mut s = session(); - for edge in [256, 384, 512, 256] { - let p = s.catalog.photo(PhotoId(1)).unwrap().clone(); - let b = thumb_bucket(edge); - // Independent original formula: never asks the new hash cache for expected values. - let settings = p.develop.hash64(); - let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; - let expected = - settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17); - let disk = Hasher128::new() - .str(&content_key(&p)) - .u64(settings) - .u64(b as u64) - .u64(RENDER_CACHE_VERSION) - .u64(crate::camera_profiles::cache_key()) - .finish(); - let job = s.thumb_job(p.id, edge).unwrap(); - assert_eq!(job.key, expected); - assert_eq!(job.cache.unwrap().1, disk); - } - s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - s.render_job(PhotoId(1), 256, 256, true, true).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - assert_eq!(s.media.settings_hashes.computations, 1); - let mut edit = DevelopSettings::default(); - edit.light.exposure = 1.0; - let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - s.thumb_job(PhotoId(1), 384).unwrap(); - assert_eq!(s.media.settings_hashes.computations, 2); - s.catalog.apply(undo).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - assert_eq!(s.media.settings_hashes.computations, 3); - } - #[test] - fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() { - let mut hashes = SettingsHashes::default(); - let shared = Arc::new(DevelopSettings::default()); - for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 { - assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64()); - } - assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES); - assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES); - assert_eq!(Arc::strong_count(&shared), 1); - let mut edit = (*shared).clone(); - edit.light.exposure = 1.0; - let edit = Arc::new(edit); - let a = PhotoId(10000); - let b = PhotoId(10001); - let initial = hashes.get(a, &shared); - assert_eq!(hashes.get(b, &shared), initial); - assert_ne!(hashes.get(a, &edit), initial); - assert_eq!(hashes.get(b, &shared), initial); - let mut s = session(); - s.thumb_job(PhotoId(1), 256).unwrap(); - s.media.clear_sources(); - assert!(s.media.settings_hashes.entries.is_empty()); - assert!(s.media.settings_hashes.order.is_empty()); - } - #[test] - fn source_from_before_content_reload_is_not_accepted() { - let mut s = session(); - s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default())))); - let old = s.thumb_job(PhotoId(1), 256).unwrap().run(); - assert!(old.loaded.is_some()); - s.catalog - .apply(Op::SetContent { - id: PhotoId(1), - width: 6000, - height: 4000, - file_size: 99, - content_hash: Some("replacement".into()), - preview_only: None, - preview_checked: String::new(), - }) - .unwrap(); - s.media.forget(PhotoId(1)); - s.accept(&old); - assert_eq!(s.media.source_usage().0, 0); - } - /// A panic while rendering (here in the decoder) used to end the pool's worker thread without - /// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now, - /// for renders and for stand-ins, answering the request it came from. - #[test] - fn a_render_that_panics_fails_instead_of_never_answering() { - let mut s = session(); - s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); - let mut job = s.thumb_job(PhotoId(1), 256).unwrap(); - job.request_id = 42; - let (key, level, source_key) = (job.key, job.level, job.source_key); - let r = job.run(); - assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key)); - assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug")); - let quick = QuickJob { - request_id: 7, - photo: PhotoId(1), - key: 9, - cached: Vec::new(), - embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), - small: None, - }; - let r = quick.run(); - assert_eq!((r.request_id, r.key), (7, 9)); - assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug")); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; - /// a draft, and a render no larger than the drawn size, are left as rendered. - #[test] - fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; - let long = plain.width.max(plain.height); - assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); - let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; - assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); - let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; - assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); - let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; - assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); - let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; - assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); - let a = s.loupe_job(id, 1024, 1024, true).unwrap(); - let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); - assert_ne!(a.key, b.key, "the fit is part of the result key"); - } - - #[test] - fn renders_know_the_original_size_whichever_level_they_come_from() { - // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the - // 512-px thumbnail level denoises like a scaled-down export - let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); - assert_eq!(source_info(&p).native_long, Some(6000)); - let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; - let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); - let info = thumb.info_or(source_info(&p)); - assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); - let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); - assert_eq!(source_info(&unknown).native_long, None); - assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); - } - - #[test] - fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { - let mut plain = Rgb32f::new(8, 8); - plain.data.fill([1.0; 3]); - let mut source = DecodedSource::new(Arc::new(plain), None); - source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8))))); - let reserved = source.bytes(); - assert!(reserved >= 3 * 8 * 8 * 12); - assert_eq!(source.image_for(0.5).data[0], [0.5; 3]); - assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost"); - let _ = source.image_for(0.25); - assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); - } - - #[test] - fn hidden_grading_wheels_render_neutral_without_touching_the_photo() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap(); - let job = s.loupe_job(id, 64, 64, true).unwrap(); - let same = job.clone().with_hidden_grading([false; 3]); - assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged"); - assert!(same.view_cache.is_some()); - let hidden = job.clone().with_hidden_grading([true, false, false]); - assert_eq!(hidden.settings.grading.shadows.sat, 0.0); - assert_eq!(hidden.settings.grading.highlights.sat, 40.0); - assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key"); - assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview"); - assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched"); - } - - #[test] - fn decoder_info_survives_render_jobs_cache_and_eviction() { - let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { - let x = 0.01 * (i + 1) as f32; - [x, (x * 2.0).min(0.9)] - })) - .unwrap(); - let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() }; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let mut job = s.render_job(id, 64, 64, false, true).unwrap(); - let loaded_info = info.clone(); - job.source = SourceRef::File { - path: "synthetic.arw".into(), - max_edge: 64, - loader: Some(Arc::new(move |_, _| { - let mut image = Rgb32f::new(64, 64); - image.data.fill([0.1; 3]); - Ok((image, loaded_info.clone())) - })), - fallback: None, - denoise: None, - reduced: None, - }; - job.info = SourceInfo::default(); // Header facts cannot override decoder facts. - job.settings = Arc::new(DevelopSettings::default()); - let r = job.clone().run(); - assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info)); - let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request); - assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data); - s.accept(&r); - job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level); - let again = job.run(); - assert!(again.loaded.is_none()); - assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data); - s.media.forget(id); - assert!(s.media.get_source(id, r.level).is_none()); - } - - /// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as - /// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices - /// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step), - /// and the panel then reads As Shot in Kelvin while the stored settings keep their look. - #[test] - fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() { - use lightcraft_develop::{WbMode, WbScale}; - let mut s = crate::Session::new(); - let id = s.catalog.alloc_photo_id(); - let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00"); - p.kind = MediaKind::Raw; - p.meta.camera = "SONY ILCE-7M3".into(); - p.as_shot_wb = Some((6500.0, 0.0)); - let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0); - (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0); - assert_eq!(d.wb.scale, WbScale::Legacy); - p.develop = Arc::new(d.clone()); - s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); - s.selection.ids = vec![id]; - s.selection.active = Some(id); - // the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known - let header = s.source_info(id); - assert!(header.raw && !header.relative_wb && header.legacy_relative_wb); - assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0)); - // the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices) - let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() }; - let mut pixels = Rgb32f::new(64, 64); - for (i, px) in pixels.data.iter_mut().enumerate() { - let v = (i % 64) as f32 / 64.0; - *px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v]; - } - let mut job = s.render_job(id, 64, 64, false, true).unwrap(); - let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone()); - job.source = SourceRef::File { - path: "/photos/DSC00001.ARW".into(), - max_edge: 64, - loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), - fallback: None, - denoise: None, - reduced: None, - }; - let undo_before = s.undo.len(); - let request = job.request; - let r = job.run(); - let new = r.rendered.as_ref().unwrap().image.data.clone(); - // the old pipeline: the same settings against the relative-scale source facts of an older build - let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; - let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; - assert_eq!(new, old, "the look of an existing edit changed"); - assert_ne!( - new, - lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data - ); - // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched - s.accept(&r); - let p = s.catalog.photo(id).unwrap(); - assert_eq!(p.as_shot_wb, Some((4986.0, -2.0))); - assert_eq!(*p.develop, d); - assert_eq!(s.undo.len(), undo_before); - assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id))); - assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom); - // the panel reads the white balance in effect in Kelvin: the value the relative edit renders as - let info = s.source_info(id); - assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0)); - let shown = lightcraft_pipeline::local::effective_wb(&info, &d); - assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0))); - assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}"); - // accepting again changes nothing more - let log = s.pending_log.len(); - s.accept(&r); - assert_eq!(s.pending_log.len(), log); - // a catalog-only (proxy) render of the same settings before the decode gave the same pixels too - let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data; - assert_eq!(proxy, old, "a smart preview of an existing edit changed"); - } - - #[test] - fn budget_evicts_least_recently_used_across_caches() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(30 * mb); - // 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes - m.insert(PhotoId(1), SourceLevel::Preview, img(1000)); - m.insert(PhotoId(2), SourceLevel::Preview, img(1000)); - assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2 - m.insert(PhotoId(3), SourceLevel::Preview, img(1000)); - assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none()); - assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some()); - // a thumbnail source and a rendered preview compete in the same budget - m.insert(PhotoId(4), SourceLevel::Thumb, img(500)); - m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000))); - assert!(m.held() <= 30 * mb, "{}", m.held()); - // the newest preview (the one on screen) is never evicted, even alone over budget - m.set_budget(mb); - assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some()); - assert_eq!(m.usage().1.count, 1); - assert_eq!(m.usage().0.count, 0); - assert_eq!(m.rendered.mem_usage().0, 0); - } - - // Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view, - // the least recently used goes, and the budget still evicts them like any source - #[test] - fn full_size_originals_are_kept_up_to_the_capacity() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(100 * mb); - m.insert(PhotoId(1), SourceLevel::Full, img(1000)); - m.insert(PhotoId(2), SourceLevel::Full, img(1000)); - assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default"); - m.set_full_capacity(2); - m.insert(PhotoId(1), SourceLevel::Full, img(1000)); - assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); - assert_eq!(m.usage().2.count, 2); - // re-inserting a held photo replaces it instead of taking a second place - m.insert(PhotoId(2), SourceLevel::Full, img(1000)); - assert_eq!(m.usage().2.count, 2); - // 1 is the least recently used now - m.set_full_capacity(1); - assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); - m.set_full_capacity(0); - assert_eq!(m.usage().2.count, 1, "never less than one"); - m.set_full_capacity(2); - m.insert(PhotoId(3), SourceLevel::Full, img(1000)); - m.set_budget(15 * mb); - assert_eq!(m.usage().2.count, 1, "the budget evicts the older original"); - assert!(m.has_source(PhotoId(3), SourceLevel::Full)); - } - - // Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay - // (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) - #[test] - fn the_on_screen_original_does_not_evict_the_other_sources() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(20 * mb); - m.set_on_screen(Some(PhotoId(1))); - m.insert(PhotoId(1), SourceLevel::Preview, img(500)); - m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); - m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB - m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour - for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { - assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); - } - } - - // A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): - // evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo - // on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. - #[test] - fn the_original_on_screen_stays_even_over_budget() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(20 * mb); - m.set_on_screen(Some(PhotoId(1))); - m.insert(PhotoId(1), SourceLevel::Preview, img(500)); - m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB - assert!(m.has_source(PhotoId(1), SourceLevel::Full)); - m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour - m.insert(PhotoId(3), SourceLevel::Thumb, img(100)); - assert!(m.has_source(PhotoId(1), SourceLevel::Full)); - m.set_on_screen(Some(PhotoId(2))); - assert!(!m.has_source(PhotoId(1), SourceLevel::Full)); - assert!(m.held() <= 20 * mb, "{}", m.held()); - } - - #[test] - fn work_gate_waits_for_room() { - use crate::memory::WorkGate; - let g = Arc::new(WorkGate::new(100)); - let a = g.acquire(80); - // more than fits waits until `a` is released; urgent work never waits - let urgent = g.acquire_urgent(500); - assert_eq!(g.usage().0, 580); - drop(urgent); - let g2 = g.clone(); - let t = std::thread::spawn(move || { - let _b = g2.acquire(50); - g2.usage().0 - }); - std::thread::sleep(std::time::Duration::from_millis(50)); - assert_eq!(g.usage().0, 80, "the second holder is still waiting"); - drop(a); - assert_eq!(t.join().unwrap(), 50); - assert_eq!(g.usage().0, 0); - // a single holder may exceed the limit - drop(g.acquire(1000)); - } - - #[test] - fn levels_cover_sizes_and_large_exports_use_the_original() { - assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); - assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview); - assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full); - let mut s = crate::Session::with_demo(); - let p = s.catalog.photos().next().unwrap().clone(); - let long = p.width.max(p.height) as usize; - assert!(long > 2560, "demo photos are camera-sized"); - // a full-size export reads the source at its native size, not an upscaled preview - let job = s.render_job(p.id, long, long, false, true).unwrap(); - assert_eq!(job.level, SourceLevel::Full); - match &job.source { - SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long), - _ => panic!("demo source expected"), - } - let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); - assert_eq!(loupe.level, SourceLevel::Preview); - assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); - // two photos with identical settings and size never share a result key - let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); - let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); - assert_ne!(a.key, b.key); - } - - // Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale, - // from the original, with a key of its own - #[test] - fn region_jobs_render_a_window_of_the_zoomed_frame() { - use lightcraft_pipeline::PixelWindow; - let mut s = crate::Session::with_demo(); - let p = s.catalog.photos().next().unwrap().clone(); - let (w, h) = (p.width as usize, p.height as usize); - assert!(w.max(h) > 2560, "demo photos are camera-sized"); - let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 }; - let job = s.region_job(p.id, w, h, win, true).unwrap(); - assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original"); - let img = job.run().rendered.unwrap().image; - assert_eq!((img.width, img.height), (300, 200)); - // other windows, and the whole-frame job, never share a key - let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap(); - let whole = s.render_job(p.id, w, h, false, true).unwrap(); - let again = s.region_job(p.id, w, h, win, true).unwrap(); - assert_ne!(other.key, again.key); - assert_ne!(whole.key, again.key); - assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key"); - // a window never writes the photo's view preview or the thumbnail cache - assert!(again.view_cache.is_none() && again.cache.is_none()); - } - - // Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview - #[test] - fn a_tight_crop_reads_the_source_level_its_pixels_need() { - let mut s = crate::Session::with_demo(); - let p = s.catalog.photos().next().unwrap().clone(); - let long = p.width.max(p.height) as usize; - assert!(long > 2560); - let id = p.id; - let mut d = (*s.develop_of(id).unwrap()).clone(); - // the uncropped photo at 1600 px: the preview is plenty - assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview); - // a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source - d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 }; - s.set_develop(id, d, "Crop").unwrap(); - assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full); - // …but with the crop tool open (the whole photo shown) it does not - assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview); - // and a crop shown small enough still reads the preview - assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); - } - - // a window whose spot reads from further away than a render can hold is refused - #[test] - fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() { - use lightcraft_pipeline::PixelWindow; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let (w, h) = (p.width as usize * 8, p.height as usize * 8); - let mut d = (*s.develop_of(id).unwrap()).clone(); - d.spots.push(lightcraft_develop::Spot { - points: vec![lightcraft_geom::Point::new(0.2, 0.5)], - size: 0.01, - source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)), - ..Default::default() - }); - s.set_develop(id, d, "Spot").unwrap(); - let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 }; - assert!(s.region_job(id, w, h, win, true).is_none()); - // a window elsewhere is fine - assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); - } - - // Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo - // without its edits (the crop is kept), with a key of its own - #[test] - fn a_before_window_shows_the_unedited_look() { - use lightcraft_pipeline::PixelWindow; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let (w, h) = (p.width as usize, p.height as usize); - let mut d = (*s.develop_of(id).unwrap()).clone(); - d.light.exposure = 2.0; - s.set_develop(id, d, "Exposure").unwrap(); - let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 }; - let after = s.region_job(id, w, h, win, true).unwrap(); - let before = s.region_job_before(id, w, h, win, true).unwrap(); - assert_ne!(after.key, before.key); - let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image); - assert_eq!((a.width, a.height), (b.width, b.height)); - let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::() / i.data.len() as f64; - assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); - } - - // LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of - // the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and - // at a corner, with tone, colour and detail edits on a camera-sized photo - #[test] - fn one_to_one_window_pixels_are_export_pixels() { - use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow}; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let (w, h) = (p.width as usize, p.height as usize); - assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}"); - let mut d = (*s.develop_of(id).unwrap()).clone(); - d.light.exposure = 0.7; - d.light.contrast = 25.0; - d.color.saturation = 20.0; - s.set_develop(id, d, "Edits").unwrap(); - let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; - assert_eq!((export.width, export.height), (w, h), "a full-size export"); - for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] { - let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image; - assert_eq!((tile.width, tile.height), (win.w, win.h)); - let mut worst = 0u8; - for y in 0..win.h { - for x in 0..win.w { - let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]); - for k in 0..3 { - worst = worst.max(a[k].abs_diff(b[k])); - } - } - } - assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels"); - } - } - - /// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here - /// one nothing can decode) is not read again. - #[test] - fn a_thumbnail_is_cut_from_the_decoded_preview() { - let mut s = crate::Session::new(); - let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); - s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); - let job = s.thumb_job(p.id, 512).unwrap(); - assert!(matches!(job.source, SourceRef::Fit { .. })); - let r = job.run(); - let thumb = r.rendered.as_ref().unwrap(); - assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); - s.accept(&r); - let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); - assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); - } - - #[test] - fn has_source_follows_what_the_session_accepted() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let long = p.width.max(p.height) as usize; - assert!(!s.media.has_source(id, SourceLevel::Full)); - let r = s.render_job(id, long, long, false, true).unwrap().run(); - s.accept(&r); - assert!(s.media.has_source(id, SourceLevel::Full)); - assert!(!s.media.has_source(id, SourceLevel::Preview)); - assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full)); - } - - #[test] - fn variant_jobs_have_their_own_keys_and_hit_the_cache() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let mine = (*s.develop_of(id).unwrap()).clone(); - let with = |pid: &str| { - let mut d = mine.clone(); - d.profile.id = pid.into(); - d - }; - let (a, b) = (with("lc.vivid"), with("lc.bw.sepia")); - let ja = s.variant_job(id, &a, 128).unwrap(); - let jb = s.variant_job(id, &b, 128).unwrap(); - assert_ne!(ja.key, jb.key, "keys differ per settings"); - assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable"); - assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size"); - assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail"); - assert_eq!(ja.level, SourceLevel::Thumb); - let (cache, ck) = ja.cache.clone().unwrap(); - assert!(cache.get(ck).is_none()); - let r = ja.run(); - s.accept(&r); - let first = r.rendered.unwrap().image; - assert!(first.width.max(first.height) <= 128); - assert!(cache.get(ck).is_some(), "rendered variants are cached"); - assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none()); - // a second job for the same variant is served from the cache, pixel for pixel - let again = s.variant_job(id, &a, 128).unwrap().run(); - assert!(again.loaded.is_none()); - assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes()); - let other = jb.run().rendered.unwrap().image; - assert_ne!(other.as_bytes(), first.as_bytes()); - // the photo's own settings never changed - assert_eq!(*s.develop_of(id).unwrap(), mine); - // hover previews: a loupe-sized job with other settings, its own key - let pv = s.preview_job(id, 800, 600, true, &a).unwrap(); - assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); - assert_eq!(*pv.settings, a); - } -} +mod tests; diff --git a/crates/engine/src/media/cache.rs b/crates/engine/src/media/cache.rs new file mode 100644 index 000000000..c3d323e38 --- /dev/null +++ b/crates/engine/src/media/cache.rs @@ -0,0 +1,365 @@ +use super::settings_hash::SettingsHashes; +use super::source::{DecodedSource, DenoiseSpec, PairLoader, RENDERED_MEM_BYTES, SourceLevel, SourceRef, THUMB_SOURCE_BYTES}; +use super::*; + +/// 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. +#[derive(Default)] +pub struct DenoiseIndex { + ready: std::collections::HashMap, + /// Reads the pictures (`None`: this build cannot, and nothing is ever ready). + pub loader: Option, +} + +impl DenoiseIndex { + /// What to load for `id` besides its plain picture, and how. + pub fn get(&self, id: PhotoId) -> Option<(&DenoiseSpec, &PairLoader)> { + self.ready.get(&id).zip(self.loader.as_ref()) + } + + pub fn spec(&self, id: PhotoId) -> Option<&DenoiseSpec> { + self.ready.get(&id) + } + + pub fn set(&mut self, id: PhotoId, spec: DenoiseSpec) { + self.ready.insert(id, spec); + } + + pub fn remove(&mut self, id: PhotoId) -> bool { + self.ready.remove(&id).is_some() + } + + pub fn clear(&mut self) { + self.ready.clear(); + } + + pub fn len(&self) -> usize { + self.ready.len() + } + + pub fn is_empty(&self) -> bool { + self.ready.is_empty() + } + + pub fn ids(&self) -> Vec { + let mut v: Vec = self.ready.keys().copied().collect(); + v.sort(); + v + } +} + +impl Default for MediaCache { + fn default() -> Self { + let budget = crate::memory::cache_share(crate::memory::budget()); + MediaCache { + settings_hashes: SettingsHashes::default(), + thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), + previews: Vec::new(), + full: Vec::new(), + full_capacity: 1, + on_screen: None, + preview_capacity: 0, + budget, + file_loader: None, + reduced_loader: None, + file_probe: None, + preview_loader: None, + file_bytes: None, + smart_dir: None, + availability: Default::default(), + scenes: Vec::new(), + rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), + denoise: DenoiseIndex::default(), + } + } +} + +/// Memory for rendered previews out of the cache share. +fn rendered_budget(share: usize) -> usize { + RENDERED_MEM_BYTES.min(share / 4) +} + +impl MediaCache { + /// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired + /// (its files stay): render jobs still holding it can't write through it any more, even + /// into a directory a later clear emptied (`dir` may be the same one). + pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { + self.rendered.retire(); + self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes)); + } + + /// Change the disk budget of the cache in `dir`: in place when that cache is attached (its + /// thumbnails are keyed by content, so they and the textures shown from them stay valid, + /// and the same cache object keeps its generation), else attach it. + pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) { + match self.rendered.disk().filter(|d| d.dir() == dir) { + Some(d) => d.set_budget(disk_bytes), + None => self.attach_disk_cache(dir, disk_bytes), + } + } + + /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). + pub fn clear_sources(&mut self) { + self.settings_hashes = SettingsHashes::default(); + self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); + self.previews.clear(); + self.full.clear(); + } + + /// How many full-resolution originals to keep at once (at least one). Compare and Reference + /// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window + /// render of one side would evict the other side's original and decode its own again. Lowering + /// it drops the least recently used originals at once. + pub fn set_full_capacity(&mut self, n: usize) { + self.full_capacity = n.max(1); + self.trim_full(); + } + + /// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays + /// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it + /// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once. + pub fn set_on_screen(&mut self, id: Option) { + if self.on_screen != id { + self.on_screen = id; + self.enforce_budget(); + } + } + + fn trim_full(&mut self) { + while self.full.len() > self.full_capacity { + let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); + self.full.remove(oldest); + } + } + + /// Bytes the caches may hold together. + pub fn budget(&self) -> usize { + self.budget + } + + /// Change the bytes the caches may hold together, evicting at once if needed. + pub fn set_budget(&mut self, bytes: usize) { + self.budget = bytes; + self.thumbs.set_budget(THUMB_SOURCE_BYTES.min(bytes)); + self.rendered.set_mem_budget(rendered_budget(bytes)); + self.enforce_budget(); + } + + /// Bytes held by decoded sources and rendered previews in memory. + pub fn held(&self) -> usize { + let (t, p, f) = self.usage(); + t.bytes + p.bytes + f.bytes + self.rendered.mem_usage().1 + } + + /// Evict least recently used entries across the source caches and the rendered previews + /// until they fit the budget. The newest preview-level source (the photo on screen, or the + /// one just decoded) is never evicted here, nor is the original of the photo on screen + /// ([`Self::set_on_screen`]). + pub fn enforce_budget(&mut self) { + // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) + // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and + // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. + let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); + let mut held = self.held().saturating_sub(pinned); + while held > self.budget { + let newest_preview = self.previews.iter().map(|e| e.2).max(); + let candidates = [ + self.thumbs.oldest_tick().map(|t| (t, 0)), + self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), + self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)), + self.rendered.oldest_tick().map(|t| (t, 3)), + ]; + let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; + let freed = match which { + 0 => self.thumbs.pop_oldest(), + 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()), + 2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()), + _ => self.rendered.evict_oldest(), + }; + match freed { + Some(b) if b > 0 => held = held.saturating_sub(b), + _ => break, + } + } + } + + pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { + self.get_source(id, level).map(|s| s.image) + } + + pub(super) fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option { + match level { + SourceLevel::Thumb => self.thumbs.get(&id).cloned(), + SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), + SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), + } + } + + /// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header` + /// (with a smart preview's stored camera tone curve). + pub(super) fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo { + let cached = || { + self.thumbs + .peek(&id) + .into_iter() + .chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1)) + .chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1)) + }; + match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) { + Some(s) => s.info_or(header), + None => header, + } + } + + pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { + self.insert_source(id, level, DecodedSource::new(img, None)); + } + + pub(super) fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) { + let tick = lightcraft_preview::next_tick(); + match level { + SourceLevel::Thumb => { + let cost = img.bytes(); + self.thumbs.insert(id, img, cost); + } + SourceLevel::Preview => { + self.previews.retain(|e| e.0 != id); + self.previews.push((id, img, tick)); + let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; + while self.previews.len() > cap { + let oldest = self.previews.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); + self.previews.remove(oldest); + } + } + SourceLevel::Full => { + self.full.retain(|e| e.0 != id); + self.full.push((id, img, tick)); + self.trim_full(); + } + } + self.enforce_budget(); + } + + /// Forget a photo's decoded sources (e.g. after its file changed). + pub fn forget(&mut self, id: PhotoId) { + self.thumbs.remove(&id); + self.previews.retain(|e| e.0 != id); + self.full.retain(|e| e.0 != id); + } + + /// (decoded thumbnail sources, bytes). + pub fn source_usage(&self) -> (usize, usize) { + (self.thumbs.len(), self.thumbs.cost()) + } + + /// Whether the decoded source of `id` at `level` is held. + pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool { + match level { + SourceLevel::Thumb => self.thumbs.contains(&id), + SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), + SourceLevel::Full => self.full.iter().any(|e| e.0 == id), + } + } + + /// Decoded sources held: (thumbnail level, preview level, full size). + pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { + use crate::memory::Usage; + let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum()); + let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum()); + (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) + } + + /// What a render of photo `id` with settings `s` adds to its identity for the denoised picture it will use: 0 unless + /// it uses one (the Denoise amount is above 0 and the photo has a cached denoised picture). + pub fn denoise_salt(&self, id: PhotoId, s: &DevelopSettings) -> u64 { + match self.denoise.spec(id) { + Some(spec) if s.denoise_amount() > 0.0 => Hasher128::new().str(&spec.key).finish().0 as u64 | 1, + _ => 0, + } + } + + /// How to load photo `p` at `level`: from the cache when its source is there, else from its file (with its denoised + /// picture beside it when it has one), its smart preview or the demo scene. + pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { + self.source_ref_with(p, level, true) + } + + /// [`Self::source_ref`] that never takes a decoded source from memory: for a job that needs something the cached + /// one lacks (an export, whose denoised picture is made when it runs). + pub fn source_ref_uncached(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { + self.source_ref_with(p, level, false) + } + + fn source_ref_with(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { + let mut r = self.source_ref_plain(p, level, cached); + if let (SourceRef::File { denoise, .. }, Some((spec, loader))) = (&mut r, self.denoise.get(p.id)) { + *denoise = Some((spec.clone(), loader.clone())); + } + r + } + + fn source_ref_plain(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { + if cached && let Some(a) = self.get_source(p.id, level) { + return SourceRef::Loaded(Box::new(a)); + } + // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not + // with a denoised picture, which the thumbnail would lose + if cached + && level == SourceLevel::Thumb + && self.denoise.spec(p.id).is_none() + && let Some(big) = self.get_source(p.id, SourceLevel::Preview) + { + return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; + } + // Procedural scenes have a nominal size: "full" is that size, not unbounded. + let max_edge = level.max_edge().min(match (&p.source, level) { + (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, + _ => usize::MAX, + }); + // the original is offline: its smart preview, when there is one. Only cached answers are + // used here (no file-system call on the UI thread in the app); an original not known to + // be offline is tried first and its smart preview is the render worker's fallback. + #[cfg(not(target_arch = "wasm32"))] + if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) { + let sharded = crate::smart::sharded_file_path(dir, p); + let legacy = dir.join(crate::smart::file_name(p)); + let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy }; + // (a proxy cut short fails to load, like the offline original; the worker's fallback + // in `SourceRef::load` only uses a proxy that passes `smart::is_valid`) + if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) { + return SourceRef::Smart { path: sp }; + } + let mut r = self.origin_ref(&p.source, max_edge); + if let SourceRef::File { fallback, .. } = &mut r { + *fallback = Some(sp); + } + return r; + } + self.origin_ref(&p.source, max_edge) + } + + /// How to load `origin` at most `max_edge` pixels long, ignoring decoded sources in memory (a + /// render worker in another wasm instance builds its sources from this). + pub fn origin_ref(&mut self, origin: &Source, max_edge: usize) -> SourceRef { + match origin { + Source::Demo { scene } => { + if self.scenes.is_empty() { + self.scenes = lightcraft_scenes::demo_library(); + } + match self.scenes.iter().find(|s| s.id == *scene) { + Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, + None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None }, + } + } + Source::File { path } => { + SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None } + } + } + } +} diff --git a/crates/engine/src/media/quick.rs b/crates/engine/src/media/quick.rs new file mode 100644 index 000000000..6098ed4dd --- /dev/null +++ b/crates/engine/src/media/quick.rs @@ -0,0 +1,127 @@ +use super::render::{RenderJob, RenderResult}; +use super::source::{PreviewLoader, SourceLevel}; +use super::*; + +/// Where a [`QuickJob`]'s stand-in image came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum QuickSource { + /// The photo's own develop render for its current settings (cached view or thumbnail render). + Cached, + /// The camera's embedded JPEG (unedited raws). + Embedded, + /// A thumbnail-level develop render made for the occasion. + Small, +} + +/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached +/// renders, the embedded preview of an unedited raw, a thumbnail-level render. +#[derive(Clone)] +pub struct QuickJob { + pub request_id: u64, + pub photo: PhotoId, + pub key: u64, + /// Cached renders to try first, in order: (cache, key). + pub cached: Vec<(Arc, Hash128)>, + /// Embedded preview of an unedited raw: (path, loader, max edge). + pub embedded: Option<(String, PreviewLoader, usize)>, + /// Last resort: run this thumbnail job. + pub small: Option>, +} + +impl QuickJob { + /// The stand-in, from the first source that has one. A panic inside is this job's failure + /// (see [`RenderJob::run`]). + pub fn run(self) -> RenderResult { + let (request_id, photo, key) = (self.request_id, self.photo, self.key); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { + request_id, + source_key: None, + photo, + level: SourceLevel::Thumb, + key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }) + } + + fn run_unguarded(self) -> RenderResult { + let (photo, key) = (self.photo, self.key); + let request_id = self.request_id; + let done = |rendered: Result, quick| RenderResult { + request_id, + source_key: None, + photo, + level: SourceLevel::Thumb, + key, + rendered, + gpu_fallback: None, + loaded: None, + quick, + display: None, + }; + let rendered = |image: Rgba8| { + let histogram = Histogram::of_srgb8(&image); + Ok(Rendered { image, histogram, deep: None }) + }; + for (cache, k) in &self.cached { + if let Some(img) = cache.get(*k) { + return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached)); + } + } + if let Some((path, loader, edge)) = &self.embedded + && let Some(image) = loader(path, *edge) + { + return done(rendered(image), Some(QuickSource::Embedded)); + } + if let Some(job) = self.small { + let r = job.run(); + return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r }; + } + done(Err("no quick preview".into()), None) + } +} + +/// What identifies a photo's pixels for caching: its content hash, else its source. +pub fn content_key(p: &Photo) -> String { + match (&p.content_hash, &p.source) { + (Some(h), _) => h.clone(), + (None, Source::Demo { scene }) => format!("demo:{scene}"), + (None, Source::File { path }) => format!("file:{path}:{}", p.file_size), + } +} + +pub fn source_info(p: &Photo) -> SourceInfo { + // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. + // (They are drawn afresh at every size, so they have no original size.) + if matches!(p.source, Source::Demo { .. }) { + return SourceInfo { raw: true, ..Default::default() }; + } + // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) + let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); + SourceInfo { native_long, ..header_info(p) } +} + +fn header_info(p: &Photo) -> SourceInfo { + // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white + // balance and the display tone curve, like any other rendered file. + if p.develops_raw() { + let relative = p.relative_wb(); + let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; + SourceInfo { + raw: true, + as_shot_temp: temp, + as_shot_tint: tint, + lens: p.embedded_lens, + relative_wb: relative, + // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`) + legacy_relative_wb: p.fitted_look() && !relative, + ..Default::default() + } + } else { + SourceInfo::default() + } +} diff --git a/crates/engine/src/media/render.rs b/crates/engine/src/media/render.rs new file mode 100644 index 000000000..1192a98e0 --- /dev/null +++ b/crates/engine/src/media/render.rs @@ -0,0 +1,377 @@ +use super::quick::QuickSource; +use super::source::{DecodedSource, SourceLevel, SourceRef}; +use super::*; + +/// Everything needed to render one photo, detached from the session. +#[derive(Clone)] +pub struct RenderJob { + /// Assigned by a frontend to distinguish late completions, even for an identical render key. + pub request_id: u64, + pub cache_generation: u64, + /// Content identity for validating a decoded source returned after a reload. + pub source_key: Option, + pub photo: PhotoId, + pub level: SourceLevel, + pub source: SourceRef, + /// Where the photo's pixels come from (for executors that can't share `source`, e.g. a web + /// worker). + pub origin: Source, + pub info: SourceInfo, + pub settings: Arc, + pub request: RenderRequest, + /// Identifies the result for caching: hash of settings + request. + pub key: u64, + /// Rendered-thumbnail cache and this job's key in it. + pub cache: Option<(Arc, Hash128)>, + /// Intermediate results of this view's previous renders (set by the frontend for the loupe): + /// slider drags then only redo the stages the changed setting feeds. + pub stages: Option>, + /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). + pub view_cache: Option<(Arc, Hash128)>, + /// Render for this monitor profile ([`Self::with_display`]). + pub display: Option>, + /// Compose the render into a slideshow slide ([`Self::with_slide`]). + pub slide: Option>, + /// A face tile that can be cut from the raw's embedded preview, tried before `source` + /// ([`crate::Session::face_job`]; `None` for every other job). + pub preview_cut: Option, + /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once + /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up + /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at + /// small ratios and aliases it at large ones. The caches keep the full render. + pub display_fit: Option<(usize, usize)>, +} + +/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). +pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { + use lightcraft_raster::resample::{Filter, fit}; + let f = lightcraft_raster::Image { + width: img.width, + height: img.height, + data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), + }; + let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } +} + +pub struct RenderResult { + pub request_id: u64, + pub source_key: Option, + pub photo: PhotoId, + pub level: SourceLevel, + pub key: u64, + pub rendered: Result, + /// Set when this job's render was tried on the GPU and fell back to the CPU: why + /// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way. + pub gpu_fallback: Option, + /// A source that was loaded by this job (to be inserted into the cache). + pub loaded: Option, + /// Set for a [`QuickJob`]'s stand-in: where the image came from. + pub quick: Option, + /// The display profile (id) the image is encoded for; `None`: sRGB (see + /// [`crate::display::present`]). + pub display: Option, +} + +impl RenderJob { + /// Render at draft quality (interactive drags). Gets its own result key. + /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see + /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. + pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { + self.display_fit = Some((w, h)); + self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self + } + + pub fn draft(mut self) -> Self { + if self.request.quality != Quality::Draft { + self.request.quality = Quality::Draft; + self.key ^= 0x9e37_79b9_7f4a_7c15; + } + self + } + + /// Draw a diagnostic overlay over the result (e.g. Point Color's visualized range). Gets its + /// own result key. + pub fn with_overlay(mut self, overlay: lightcraft_pipeline::Overlay) -> Self { + if self.request.overlay != overlay { + self.key ^= self.request.overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + self.request.overlay = overlay; + self.key ^= overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + } + // a diagnostic view (visualized range / spots) must never become the photo's cached preview + if overlay != lightcraft_pipeline::Overlay::None { + self.view_cache = None; + } + self + } + + /// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is + /// never kept as the photo's cached preview. + pub fn with_proof(mut self, proof: Option) -> Self { + if self.request.proof != proof { + let k = |p: Option| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f); + self.key ^= k(self.request.proof) ^ k(proof); + self.request.proof = proof; + } + if proof.is_some() { + self.view_cache = None; + } + self + } + + /// Render with some colour grading wheels (shadows, midtones, highlights) switched off for + /// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result + /// gets its own key and is never cached as the photo's preview or thumbnail. + pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self { + if !hidden.iter().any(|h| *h) { + return self; + } + let mut s = (*self.settings).clone(); + let g = &mut s.grading; + for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) { + if off { + *wheel = lightcraft_develop::Wheel::default(); + } + } + let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i)); + self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93); + self.settings = Arc::new(s); + self.cache = None; + self.view_cache = None; + self.preview_cut = None; + self + } + + /// Reuse `stages` across this view's renders. + pub fn with_stages(mut self, stages: Arc) -> Self { + self.stages = Some(stages); + self + } + + /// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready + /// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB. + /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: + /// [`crate::display::present`] converts their sRGB results. + pub fn with_display(mut self, display: Option>) -> Self { + // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) + if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { + return self; + } + let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); + self.key ^= k(&self.display) ^ k(&display); + self.request.display = display.as_ref().map(|d| d.space); + self.display = display; + self + } + + /// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting + /// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace + /// it, so a few such panics used to stop every render). + pub fn run(self) -> RenderResult { + let failed = self.failed(String::new()); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed }) + } + + /// This job's result when it fails with `e`. + fn failed(&self, e: String) -> RenderResult { + RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + } + } + + /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the + /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the + /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). + pub fn with_slide(mut self, slide: Arc) -> Self { + let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); + self.key ^= k(&self.slide); + self.key ^= k(&Some(slide.clone())); + self.slide = Some(slide); + self + } + + /// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard). + fn run_unguarded(self) -> RenderResult { + let slide = self.slide.clone(); + let mut r = self.run_photo(); + if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { + rendered.image = slide.compose(&rendered.image); + rendered.deep = None; + } + r + } + + /// The photo's render (without slide composition). + fn run_photo(self) -> RenderResult { + if let Some((cache, key)) = &self.cache + && let Some(img) = cache.get_at(self.cache_generation, *key) + { + let image = Arc::unwrap_or_clone(img); + let histogram = Histogram::of_srgb8(&image); + return RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }; + } + // a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough + // (the sensor data is never read for it), else rendered from `source` below + if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) { + if let Some((cache, key)) = &self.cache { + cache.put_at(self.cache_generation, *key, Arc::new(image.clone())); + } + let histogram = Histogram::of_srgb8(&image); + return RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }; + } + let was_loaded = matches!(self.source, SourceRef::Loaded(_)); + // The app is closing (issue #620): nobody is left to show this, and quitting waits for the + // jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU. + let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string()); + let source = match closing() { + Some(e) => Err(e), + None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)), + }; + match source { + Ok(source) => { + // the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for + let src = &source.image_for(self.settings.denoise_amount()); + let info = source.info_or(self.info); + // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use + // the GPU when there is one. + let gpu = self.cache.is_none(); + let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + // for a display profile: the image goes to the display's values, the histogram + // and the cached view preview stay sRGB + let mut srgb = None; + let display = match (&self.display, self.request.display) { + (Some(d), Some(_)) => { + let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; + let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); + // (an HDR edit's HDR histogram stays) + if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) { + rendered.histogram = h; + } + srgb = s; + Some(d.id()) + } + _ => None, + }; + if let Some((cache, key)) = &self.cache { + cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); + } + if let Some((cache, key)) = &self.view_cache + && self.request.quality == Quality::Full + && (display.is_none() || srgb.is_some()) + { + cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); + } + // the display fit, after the caches have the full render + if let Some((fw, fh)) = self.display_fit + && self.request.quality == Quality::Full + { + let (w, h) = (rendered.image.width, rendered.image.height); + if w > fw.max(1) || h > fh.max(1) { + rendered.image = fit_for_display(&rendered.image, fw, fh); + } + } + RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(rendered), + gpu_fallback, + loaded: (!was_loaded).then_some(source), + quick: None, + display, + } + } + Err(e) => self.failed(e), + } + } +} + +/// Render `src`: on the GPU when `gpu` is set and a GPU is available (`lightcraft_gpu`), else on +/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see +/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. +pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { + develop_reporting(src, info, s, req, stages, gpu).0 +} + +/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`: +/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the +/// process-wide [`lightcraft_gpu::last_fallback`]. +pub fn develop_reporting( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, +) -> (Rendered, Option) { + let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu); + // an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead + if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) { + rendered.histogram = h; + } + (rendered, fallback) +} + +fn develop_image( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, +) -> (Rendered, Option) { + // LUT profiles have no GPU stage: they render on the CPU + let fallback = if gpu + && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) + && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) + && req.proof.is_none() + { + match lightcraft_gpu::try_render(src, info, s, req, stages) { + Ok(r) => return (r, None), + Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why), + Err(lightcraft_gpu::Fallback::NotTried) => None, + } + } else { + None + }; + let rendered = match stages { + Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st), + None => lightcraft_pipeline::render(src, info, s, req), + }; + (rendered, fallback) +} diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs new file mode 100644 index 000000000..d160b216d --- /dev/null +++ b/crates/engine/src/media/session.rs @@ -0,0 +1,476 @@ +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::*; + +impl crate::Session { + /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. + pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option { + self.build_job(id, max_w, max_h, before, apply_crop, None) + } + + /// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`] + /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). + pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { + let b = thumb_bucket(long_edge); + let mut job = self.build_job(id, b, b, false, true, Some(b))?; + // (a thumbnail may develop a smaller copy of the raw image that the file keeps) + if let SourceRef::File { reduced, .. } = &mut job.source { + reduced.clone_from(&self.media.reduced_loader); + } + Some(job) + } + + fn build_job( + &mut self, + id: PhotoId, + max_w: usize, + max_h: usize, + before: bool, + apply_crop: bool, + thumb_bucket: Option, + ) -> Option { + let p = self.catalog.photo(id)?.clone(); + let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() }; + let level = SourceLevel::for_size(if thumb_bucket.is_some() { + max_w.max(max_h) // (grid thumbnails of tight crops stay cheap) + } else { + source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h)) + }); + let source = self.media.source_ref(&p, level); + let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; + // the photo id is part of the key: two photos with the same settings and size must not + // share a result (a view slot showing photo A would otherwise look current for photo B) + // …and so is the file's content: a file changed on disk (Reload) renders afresh + let content_key = content_key(&p); + let source_key = Hasher128::new().str(&content_key).finish(); + let content = source_key.0 as u64; + // Temporary "before" settings must not replace the photo's cached develop hash. + let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) }; + let salt = self.media.denoise_salt(id, &settings); + let key = settings_hash + ^ ((max_w as u64) << 40) + ^ ((max_h as u64) << 20) + ^ (apply_crop as u64) + ^ (level as u64) << 60 + ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) + ^ content.rotate_left(17) + ^ salt; + let cache = thumb_bucket.map(|b| { + let mut k = Hasher128::new(); + k.str(&content_key).u64(settings_hash).u64(b as u64).u64(RENDER_CACHE_VERSION).u64(crate::camera_profiles::cache_key()); + // (only a render that uses a denoised picture has it in its key, so every other thumbnail stays valid) + if salt != 0 { + k.u64(salt); + } + (self.media.rendered.clone(), k.finish()) + }); + Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(source_key), + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings, + request, + key, + cache, + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut: None, + display_fit: None, + }) + } + + /// A render of `id` with `settings` in place of its own, fitting `max_w × max_h` (the loupe's + /// temporary preview while hovering a preset or profile; nothing is committed or cached). + pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option { + let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; + job.settings = Arc::new(settings.clone()); + job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60; + Some(job) + } + + /// Cache key of a variant thumbnail ([`Self::variant_job`]). + pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { + Hasher128::new() + .str(&content_key(p)) + .str("variant") + .u64(settings.hash64()) + .u64(edge as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish() + } + + /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset + /// browsers): from the thumbnail-level source, long edge `edge` (≤ 512), cropped. The result + /// is cached (memory + the library's disk cache) under the photo's content and the settings + /// hash, so a variant renders once; `key` is derived from the same hash, so a frontend can + /// keep one texture per variant. + pub fn variant_job(&mut self, id: PhotoId, settings: &DevelopSettings, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); + let level = SourceLevel::Thumb; + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, settings, edge); + Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings.clone()), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut: None, + display_fit: None, + }) + } + + /// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the + /// photo's own look with its crop replaced by a square around the face (room for hair and chin), + /// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size + /// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's + /// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the + /// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source + /// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the + /// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows + /// the content: a later launch shows the tile without reading the original. + pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the + // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. + let orient = p.develop.orientation; + let face = orient.map_norm_rect(face); + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; + let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; + // finite, within the photo, and never wider than its short side (so the clamps below are valid) + let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; + let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h); + let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 }); + let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0); + let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h }; + let mut settings = (*p.develop).clone(); + settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() }; + let edge = face_edge(edge); + // pixels along the long edge that leave `edge` across the crop + let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64); + let level = SourceLevel::for_size(needed as usize); + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, &settings, edge); + let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize); + Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut, + display_fit: None, + }) + } + + /// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an + /// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as + /// imported, with a preview loader installed. `rect` is the square around the face, `edge` the + /// tile, `needed` the source's long edge the develop render would ask for. + fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option { + let (path, loader) = self.embedded_of(p)?; + if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0 + { + return None; + } + let want = needed.max(edge).max(1); + let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize; + let aspect = f64::from(p.width) / f64::from(p.height); + Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge }) + } + + /// Size-independent cache key of a photo's view render (loupe) for its current settings. + fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { + Hasher128::new() + .str(&content_key(p)) + .str("view") + .u64(p.develop.hash64()) + .u64(apply_crop as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish() + } + + /// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the + /// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`]. + pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option { + let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; + let p = self.catalog.photo(id)?; + job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop))); + Some(job) + } + + /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame + /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and + /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. + /// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no + /// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold. + pub fn region_job( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + self.region_job_of(id, full_w, full_h, window, apply_crop, false) + } + + /// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side. + pub fn region_job_before( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + self.region_job_of(id, full_w, full_h, window, apply_crop, true) + } + + fn region_job_of( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + before: bool, + ) -> Option { + let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?; + // what the window's spots and Auto Mask strokes read must fit in one render: else the + // caller keeps the whole-frame render (a window alone would come out wrong) + let p = self.catalog.photo(id)?; + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + p.width.max(1) as usize, + p.height.max(1) as usize, + &job.settings, + apply_crop, + p.embedded_lens.as_ref(), + ); + let ppl = frame.px_per_long(full_w); + lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?; + job.request.window = Some(window); + job.key = Hasher128::new() + .u64(job.key) + .str("window") + .u64(window.x as u64) + .u64(window.y as u64) + .u64(window.w as u64) + .u64(window.h as u64) + .finish() + .0 as u64; + Some(job) + } + + /// The embedded preview of an unedited raw (path, loader), when the app installed a loader. + fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { + match (&p.source, &self.media.preview_loader) { + (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())), + _ => None, + } + } + + /// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor + /// decode behind it. + /// + /// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws + /// likewise start from the embedded preview"), and then paid for a render of the raw to replace + /// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px + /// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is + /// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind + /// it delayed the render the user was waiting for. + /// + /// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what + /// says the photo still looks the way it did at import. A photo the user edits re-renders + /// through the ordinary path, because its thumbnail key (settings hash) changes. + pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool { + self.embedded_of(p).is_some() + } + + /// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the + /// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the + /// raw. `None` for anything else, and where the host installed no loader. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn scan_preview_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { + match (&p.source, &self.media.preview_loader) { + (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw => Some((path.clone(), l.clone())), + _ => None, + } + } + + /// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view + /// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail. + pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option { + let p = self.catalog.photo(id)?.clone(); + let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?; + // (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview) + let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))]; + let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish(); + let key = h.0 as u64; + let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge()))); + Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) + } + + /// A first grid thumbnail for an unedited raw: the cached render for `job` (the real + /// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries + /// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job` + /// itself: the grid queues no render behind an unedited raw's quick thumbnail + /// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the + /// cell empty (issue #1448). `None` for other photos (render them directly). + pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option { + let p = self.catalog.photo(job.photo)?.clone(); + let (path, l) = self.embedded_of(&p)?; + let edge = job.request.max_w.max(job.request.max_h); + Some(QuickJob { + request_id: 0, + photo: job.photo, + key: job.key, + cached: job.cache.clone().into_iter().collect(), + embedded: Some((path, l, edge)), + small: Some(Box::new(job.clone())), + }) + } + + /// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog + /// has wrong is corrected too ([`Self::learn_as_shot_wb`]). + pub fn accept(&mut self, r: &RenderResult) { + if let Some(src) = &r.loaded + && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) + { + if let Some(info) = &src.info { + self.learn_as_shot_wb(r.photo, info); + } + self.media.insert_source(r.photo, r.level, src.clone()); + } + } + + /// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another: + /// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's + /// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not + /// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot + /// says and what a smart preview renders with. Only for raws edited in Kelvin. + fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { + let Some(p) = self.catalog.photo(id) else { return }; + if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { + return; + } + let read = (info.as_shot_temp, info.as_shot_tint); + if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) { + return; + } + if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) { + log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0); + } + } + + /// Synchronous render (CLI, MCP, tests). + pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { + let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + let r = job.run(); + self.accept(&r); + r.rendered + } + + /// Synchronous render for export: like [`Self::render_now`], into colour space `space` with + /// sample format `depth`. + pub fn render_export( + &mut self, + id: PhotoId, + max_w: usize, + max_h: usize, + space: lightcraft_pipeline::OutputSpace, + depth: lightcraft_pipeline::OutputDepth, + ) -> Result { + let r = self.export_job(id, max_w, max_h, space, depth)?.run(); + self.accept(&r); + r.rendered + } + + /// The job [`Self::render_export`] runs, to run elsewhere (e.g. a background export thread). + pub fn export_job( + &mut self, + id: PhotoId, + max_w: usize, + max_h: usize, + space: lightcraft_pipeline::OutputSpace, + depth: lightcraft_pipeline::OutputDepth, + ) -> Result { + let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw + // image that the file keeps (Lightroom's merged DNGs) + let reduced = match &job.source { + SourceRef::Loaded(s) => s.reduced, + SourceRef::Fit { from, .. } => from.reduced, + _ => false, + }; + if reduced { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + job.source = self.media.source_ref_uncached(&p, job.level); + } + // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the + // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) + if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + job.source = self.media.source_ref_uncached(&p, job.level); + if let SourceRef::File { denoise, .. } = &mut job.source { + *denoise = Some(wanted); + } + } + job.request.space = space; + job.request.depth = depth; + job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); + Ok(job) + } + + /// Prefer decoder facts to header-only metadata for pixel-statistics commands. + pub fn source_info(&self, id: PhotoId) -> SourceInfo { + let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default(); + self.media.source_facts(id, header) + } + + /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. + pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result, String> { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + let r = self.media.source_ref(&p, level).load_source()?; + let image = r.image.clone(); + if let Some(info) = &r.info { + self.learn_as_shot_wb(id, info); + } + self.media.insert_source(id, level, r); + Ok(image) + } +} diff --git a/crates/engine/src/media/settings_hash.rs b/crates/engine/src/media/settings_hash.rs new file mode 100644 index 000000000..5d9c08b93 --- /dev/null +++ b/crates/engine/src/media/settings_hash.rs @@ -0,0 +1,36 @@ +use super::*; + +pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024; + +#[derive(Default)] +pub(super) struct SettingsHashes { + pub(super) entries: HashMap, u64)>, + pub(super) order: VecDeque, + #[cfg(test)] + pub(super) computations: usize, +} + +impl SettingsHashes { + pub(super) fn get(&mut self, id: PhotoId, settings: &Arc) -> u64 { + if let Some((old, hash)) = self.entries.get(&id) + && old.as_ptr() == Arc::as_ptr(settings) + { + return *hash; + } + let hash = settings.hash64(); + #[cfg(test)] + { + self.computations += 1; + } + if !self.entries.contains_key(&id) { + if self.entries.len() >= SETTINGS_HASH_ENTRIES + && let Some(oldest) = self.order.pop_front() + { + self.entries.remove(&oldest); + } + self.order.push_back(id); + } + self.entries.insert(id, (Arc::downgrade(settings), hash)); + hash + } +} diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs new file mode 100644 index 000000000..51c54c60a --- /dev/null +++ b/crates/engine/src/media/source.rs @@ -0,0 +1,324 @@ +use super::*; + +/// Bump when the pipeline's output changes, to invalidate cached thumbnails. +pub const RENDER_CACHE_VERSION: u64 = 70; + +/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). +pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; + +/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the +/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size +/// in between would render (and cache) every face again. +pub const FACE_EDGE_STEP: usize = 16; + +/// The edge a face tile asked to be `edge` pixels across is rendered at: the next +/// [`FACE_EDGE_STEP`], 16..=512. +pub fn face_edge(edge: usize) -> usize { + edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge()) +} + +pub fn thumb_bucket(long_edge: usize) -> usize { + THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512) +} + +/// Memory budgets. +pub(super) const THUMB_SOURCE_BYTES: usize = 384 << 20; + +pub(super) const RENDERED_MEM_BYTES: usize = 128 << 20; + +/// Disk budget for the thumbnail cache. +pub const DISK_CACHE_BYTES: u64 = 2 << 30; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SourceLevel { + Thumb, + Preview, + /// The original at its native resolution (exports larger than a preview). + Full, +} + +impl SourceLevel { + pub fn max_edge(self) -> usize { + match self { + SourceLevel::Thumb => 512, + SourceLevel::Preview => 2560, + SourceLevel::Full => usize::MAX, + } + } + /// Smallest level that can serve an output of `long_edge` pixels. + pub fn for_size(long_edge: usize) -> SourceLevel { + match long_edge { + 0..=520 => SourceLevel::Thumb, + 521..=2560 => SourceLevel::Preview, + _ => SourceLevel::Full, + } + } +} + +/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows +/// only part of the photo, so its pixels come from a source that many times larger (the source +/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview). +pub(super) fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize { + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let r = settings.crop.geometry.rect; + let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) }; + if !shown.is_finite() || shown < 1.0 { + return out_long; + } + let needed = out_long as f64 * w.max(h) / shown; + if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long } +} + +/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). +pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; + +/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs) +/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export, +/// which must render the full image. +pub type ReducedLoader = Arc Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>; + +/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than +/// `max_edge` (set by the app). `None`: no usable preview. +pub type PreviewLoader = Arc Option + Send + Sync>; + +/// Makes a photo's denoised picture when it is not cached yet (an export does not wait for the background queue): +/// `Ok(true)` once it is there, `Ok(false)` when denoise does not apply to this photo (render it plain), `Err` when it +/// should have worked and did not. +pub type MakeProduct = Arc Result + Send + Sync>; + +/// A raw photo's cached AI-denoised picture (see [`crate::denoise`]): where the file is and the key it must have been +/// made with. A file that is missing, damaged or made for something else is a cache miss, never an error. +#[derive(Clone)] +pub struct DenoiseSpec { + pub product: std::path::PathBuf, + pub key: String, + /// Run before the picture is read, by a job that must not render without it (an export). + pub make: Option, +} + +/// Decodes a raw file once and develops it twice: as usual, and from the denoised picture in `spec` when that can be +/// read (`None` when it cannot). Both are at most `max_edge` long and the same size. +pub type PairLoader = Arc Result<(Rgb32f, Option, SourceInfo), String> + Send + Sync>; + +/// A photo's denoised picture next to its plain one, and the last mix of the two that was asked for. +pub struct Twin { + pub image: Arc, + /// The Amount (in thousandths) and the picture mixed for it, so dragging other sliders keeps one source picture. + mix: std::sync::Mutex)>>, +} + +impl Twin { + pub fn new(image: Arc) -> Twin { + Twin { image, mix: std::sync::Mutex::new(None) } + } + + /// Bytes reserved for the twin and its lazy mix, before either cache cost can grow. + fn bytes(&self) -> usize { + self.image.data.len().saturating_mul(24) + } +} + +/// Pixels and the source interpretation learned while decoding; they must be cached together. +#[derive(Clone)] +pub struct DecodedSource { + pub image: Arc, + pub info: Option, + /// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the + /// catalog's header facts lack (used when `info` is `None`). + pub camera_tone: Option, + /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. + pub denoised: Option>, + /// These pixels came from the photo's smart preview (an offline original), not from its file. + /// A refresh must never rebuild a proxy from itself: that would stamp its older look as current. + pub from_proxy: bool, + /// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image + /// itself: good for a thumbnail, never for an export. + pub reduced: bool, +} + +impl DecodedSource { + pub fn new(image: Arc, info: Option) -> Self { + DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false } + } + + /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, + /// the denoised one, or a mix of the two. A mix is made once per amount and kept, so the stages after it are + /// reused until the amount changes. + pub fn image_for(&self, amount: f32) -> Arc { + let Some(twin) = &self.denoised else { return self.image.clone() }; + if amount.is_nan() || amount <= 0.0 || twin.image.width != self.image.width || twin.image.height != self.image.height { + return self.image.clone(); + } + if amount >= 1.0 { + return twin.image.clone(); + } + let thousandths = (amount * 1000.0).round().clamp(1.0, 999.0) as u32; + let mut memo = twin.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((t, img)) = memo.as_ref() + && *t == thousandths + { + return img.clone(); + } + let mut mixed = (*self.image).clone(); + if !lightcraft_denoise::product::blend(&mut mixed, &twin.image, thousandths as f32 / 1000.0) { + return self.image.clone(); + } + let mixed = Arc::new(mixed); + *memo = Some((thousandths, mixed.clone())); + mixed + } + + /// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix. + pub fn bytes(&self) -> usize { + let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes()); + let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4); + self.image.data.len() * 12 + 64 + std::mem::size_of::() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes()) + } + + /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) + /// with any stored camera tone curve. The original's size comes from `header` either way: the + /// decoder only sees the level it decoded. + pub fn info_or(&self, header: SourceInfo) -> SourceInfo { + match &self.info { + Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, + None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, + } + } +} + +#[derive(Clone)] +pub enum SourceRef { + Loaded(Box), + /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of + /// the photo open in the loupe is cut from its preview-level source instead of decoding the + /// file again (a second or more for a 64 MP raw). + Fit { + from: Box, + max_edge: usize, + }, + Demo { + scene: Box, + max_edge: usize, + }, + File { + path: String, + max_edge: usize, + loader: Option, + /// The photo's smart preview, used when the original can't be read (offline drive). The + /// UI thread never checks whether the original is there; the render worker finds out. + fallback: Option, + /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). + denoise: Option<(DenoiseSpec, PairLoader)>, + /// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job. + reduced: Option, + }, + /// A smart preview standing in for a missing original. + Smart { + path: std::path::PathBuf, + }, +} + +impl SourceRef { + pub fn load(&self) -> Result, String> { + self.load_source().map(|s| s.image) + } + + pub fn load_source(&self) -> Result { + let image = match self { + SourceRef::Loaded(a) => return Ok((**a).clone()), + SourceRef::Fit { from, max_edge } => { + if from.image.width.max(from.image.height) <= *max_edge { + return Ok((**from).clone()); + } + // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near + // the output's resolution limit and aliases what is finer + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); + return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); + } + SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, + SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => { + let plain = || match (reduced, loader) { + (Some(l), _) => { + l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) }) + } + (None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), + (None, None) => Err(format!("no decoder available for {path}")), + }; + // the denoised picture comes with the plain one from a single decode; when it cannot be read the + // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) + let r = match denoise { + Some((spec, pair)) => { + // a job that needs the denoised picture has it made first; if that fails the job fails (no smart + // preview stands in: an export must not quietly come out without the denoise it was asked for) + let made = match &spec.make { + Some(make) => make()?, + None => true, + }; + if made { + pair(path, *max_edge, spec).and_then(|(image, twin, info)| { + if spec.make.is_some() && twin.is_none() { + return Err("the cached AI Denoise picture could not be read; clear the denoise cache and retry".into()); + } + Ok(DecodedSource { + image: Arc::new(image), + info: Some(info), + camera_tone: None, + denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), + from_proxy: false, + reduced: false, + }) + }) + } else { + plain() + } + } + None => plain(), + }; + // An offline original renders from its smart preview (no decoder facts: header ones). + return match (r, fallback) { + #[cfg(not(target_arch = "wasm32"))] + (Err(e), Some(sp)) if denoise.as_ref().is_none_or(|(spec, _)| spec.make.is_none()) && crate::smart::is_valid(sp) => { + crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")) + } + (r, _) => r, + }; + } + #[cfg(not(target_arch = "wasm32"))] + SourceRef::Smart { path } => return crate::smart::load(path), + #[cfg(target_arch = "wasm32")] + SourceRef::Smart { .. } => return Err("smart previews are not available here".into()), + }; + Ok(DecodedSource::new(image, None)) + } +} + +/// What an import learns from a file header (set by the app from `lightcraft-codecs`/`-raw`). +#[derive(Clone, Debug, Default)] +pub struct ProbeInfo { + pub width: u32, + pub height: u32, + pub format: String, + pub kind: MediaKind, + pub file_size: u64, + pub captured: Option, + /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the + /// capture date of a file whose metadata has none, as Lightroom does (exports saved without + /// metadata would otherwise all land under "Unknown Date"). + pub file_modified: Option, + pub meta: lightcraft_catalog::Meta, + pub as_shot_wb: Option<(f64, f64)>, + /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit + /// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it), + /// Import → Copy verifies each copy against it instead of reading the source again. + pub content_hash: Option, + /// Lens corrections embedded in the file (DNG opcodes). + pub embedded_lens: Option, + /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file. + pub xmp: Option, + /// A raw variant that can't be decoded yet: why. The file is described (and will be shown and + /// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`]. + pub preview_only: Option, +} + +pub type FileProbe = Arc Result + Send + Sync>; diff --git a/crates/engine/src/media/tests.rs b/crates/engine/src/media/tests.rs new file mode 100644 index 000000000..d36284262 --- /dev/null +++ b/crates/engine/src/media/tests.rs @@ -0,0 +1,519 @@ +use super::*; + +/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; +/// a draft, and a render no larger than the drawn size, are left as rendered. +#[test] +fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; + let long = plain.width.max(plain.height); + assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); + let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); + let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; + assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); + let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); + let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; + assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); + let a = s.loupe_job(id, 1024, 1024, true).unwrap(); + let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); + assert_ne!(a.key, b.key, "the fit is part of the result key"); +} + +#[test] +fn renders_know_the_original_size_whichever_level_they_come_from() { + // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the + // 512-px thumbnail level denoises like a scaled-down export + let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); + assert_eq!(source_info(&p).native_long, Some(6000)); + let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; + let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); + let info = thumb.info_or(source_info(&p)); + assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); + let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); + assert_eq!(source_info(&unknown).native_long, None); + assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); +} + +#[test] +fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { + let mut plain = Rgb32f::new(8, 8); + plain.data.fill([1.0; 3]); + let mut source = DecodedSource::new(Arc::new(plain), None); + source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8))))); + let reserved = source.bytes(); + assert!(reserved >= 3 * 8 * 8 * 12); + assert_eq!(source.image_for(0.5).data[0], [0.5; 3]); + assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost"); + let _ = source.image_for(0.25); + assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); +} + +#[test] +fn hidden_grading_wheels_render_neutral_without_touching_the_photo() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap(); + let job = s.loupe_job(id, 64, 64, true).unwrap(); + let same = job.clone().with_hidden_grading([false; 3]); + assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged"); + assert!(same.view_cache.is_some()); + let hidden = job.clone().with_hidden_grading([true, false, false]); + assert_eq!(hidden.settings.grading.shadows.sat, 0.0); + assert_eq!(hidden.settings.grading.highlights.sat, 40.0); + assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key"); + assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview"); + assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched"); +} + +#[test] +fn decoder_info_survives_render_jobs_cache_and_eviction() { + let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { + let x = 0.01 * (i + 1) as f32; + [x, (x * 2.0).min(0.9)] + })) + .unwrap(); + let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() }; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let mut job = s.render_job(id, 64, 64, false, true).unwrap(); + let loaded_info = info.clone(); + job.source = SourceRef::File { + path: "synthetic.arw".into(), + max_edge: 64, + loader: Some(Arc::new(move |_, _| { + let mut image = Rgb32f::new(64, 64); + image.data.fill([0.1; 3]); + Ok((image, loaded_info.clone())) + })), + fallback: None, + denoise: None, + reduced: None, + }; + job.info = SourceInfo::default(); // Header facts cannot override decoder facts. + job.settings = Arc::new(DevelopSettings::default()); + let r = job.clone().run(); + assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info)); + let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request); + assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data); + s.accept(&r); + job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level); + let again = job.run(); + assert!(again.loaded.is_none()); + assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data); + s.media.forget(id); + assert!(s.media.get_source(id, r.level).is_none()); +} + +/// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as +/// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices +/// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step), +/// and the panel then reads As Shot in Kelvin while the stored settings keep their look. +#[test] +fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() { + use lightcraft_develop::{WbMode, WbScale}; + let mut s = crate::Session::new(); + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = "SONY ILCE-7M3".into(); + p.as_shot_wb = Some((6500.0, 0.0)); + let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0); + (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + assert_eq!(d.wb.scale, WbScale::Legacy); + p.develop = Arc::new(d.clone()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.selection.ids = vec![id]; + s.selection.active = Some(id); + // the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known + let header = s.source_info(id); + assert!(header.raw && !header.relative_wb && header.legacy_relative_wb); + assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0)); + // the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices) + let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() }; + let mut pixels = Rgb32f::new(64, 64); + for (i, px) in pixels.data.iter_mut().enumerate() { + let v = (i % 64) as f32 / 64.0; + *px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v]; + } + let mut job = s.render_job(id, 64, 64, false, true).unwrap(); + let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone()); + job.source = SourceRef::File { + path: "/photos/DSC00001.ARW".into(), + max_edge: 64, + loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), + fallback: None, + denoise: None, + reduced: None, + }; + let undo_before = s.undo.len(); + let request = job.request; + let r = job.run(); + let new = r.rendered.as_ref().unwrap().image.data.clone(); + // the old pipeline: the same settings against the relative-scale source facts of an older build + let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; + let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; + assert_eq!(new, old, "the look of an existing edit changed"); + assert_ne!(new, lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data); + // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched + s.accept(&r); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.as_shot_wb, Some((4986.0, -2.0))); + assert_eq!(*p.develop, d); + assert_eq!(s.undo.len(), undo_before); + assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id))); + assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom); + // the panel reads the white balance in effect in Kelvin: the value the relative edit renders as + let info = s.source_info(id); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0)); + let shown = lightcraft_pipeline::local::effective_wb(&info, &d); + assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0))); + assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}"); + // accepting again changes nothing more + let log = s.pending_log.len(); + s.accept(&r); + assert_eq!(s.pending_log.len(), log); + // a catalog-only (proxy) render of the same settings before the decode gave the same pixels too + let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data; + assert_eq!(proxy, old, "a smart preview of an existing edit changed"); +} + +#[test] +fn budget_evicts_least_recently_used_across_caches() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(30 * mb); + // 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes + m.insert(PhotoId(1), SourceLevel::Preview, img(1000)); + m.insert(PhotoId(2), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2 + m.insert(PhotoId(3), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none()); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some()); + // a thumbnail source and a rendered preview compete in the same budget + m.insert(PhotoId(4), SourceLevel::Thumb, img(500)); + m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000))); + assert!(m.held() <= 30 * mb, "{}", m.held()); + // the newest preview (the one on screen) is never evicted, even alone over budget + m.set_budget(mb); + assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some()); + assert_eq!(m.usage().1.count, 1); + assert_eq!(m.usage().0.count, 0); + assert_eq!(m.rendered.mem_usage().0, 0); +} + +// Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view, +// the least recently used goes, and the budget still evicts them like any source +#[test] +fn full_size_originals_are_kept_up_to_the_capacity() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(100 * mb); + m.insert(PhotoId(1), SourceLevel::Full, img(1000)); + m.insert(PhotoId(2), SourceLevel::Full, img(1000)); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default"); + m.set_full_capacity(2); + m.insert(PhotoId(1), SourceLevel::Full, img(1000)); + assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); + assert_eq!(m.usage().2.count, 2); + // re-inserting a held photo replaces it instead of taking a second place + m.insert(PhotoId(2), SourceLevel::Full, img(1000)); + assert_eq!(m.usage().2.count, 2); + // 1 is the least recently used now + m.set_full_capacity(1); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); + m.set_full_capacity(0); + assert_eq!(m.usage().2.count, 1, "never less than one"); + m.set_full_capacity(2); + m.insert(PhotoId(3), SourceLevel::Full, img(1000)); + m.set_budget(15 * mb); + assert_eq!(m.usage().2.count, 1, "the budget evicts the older original"); + assert!(m.has_source(PhotoId(3), SourceLevel::Full)); +} + +// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay +// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) +#[test] +fn the_on_screen_original_does_not_evict_the_other_sources() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { + assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); + } +} + +// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): +// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo +// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. +#[test] +fn the_original_on_screen_stays_even_over_budget() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + m.insert(PhotoId(3), SourceLevel::Thumb, img(100)); + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.set_on_screen(Some(PhotoId(2))); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full)); + assert!(m.held() <= 20 * mb, "{}", m.held()); +} + +#[test] +fn work_gate_waits_for_room() { + use crate::memory::WorkGate; + let g = Arc::new(WorkGate::new(100)); + let a = g.acquire(80); + // more than fits waits until `a` is released; urgent work never waits + let urgent = g.acquire_urgent(500); + assert_eq!(g.usage().0, 580); + drop(urgent); + let g2 = g.clone(); + let t = std::thread::spawn(move || { + let _b = g2.acquire(50); + g2.usage().0 + }); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!(g.usage().0, 80, "the second holder is still waiting"); + drop(a); + assert_eq!(t.join().unwrap(), 50); + assert_eq!(g.usage().0, 0); + // a single holder may exceed the limit + drop(g.acquire(1000)); +} + +#[test] +fn levels_cover_sizes_and_large_exports_use_the_original() { + assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); + assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview); + assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full); + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(long > 2560, "demo photos are camera-sized"); + // a full-size export reads the source at its native size, not an upscaled preview + let job = s.render_job(p.id, long, long, false, true).unwrap(); + assert_eq!(job.level, SourceLevel::Full); + match &job.source { + SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long), + _ => panic!("demo source expected"), + } + let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); + assert_eq!(loupe.level, SourceLevel::Preview); + assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); + // two photos with identical settings and size never share a result key + let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); + let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); + assert_ne!(a.key, b.key); +} + +// Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale, +// from the original, with a key of its own +#[test] +fn region_jobs_render_a_window_of_the_zoomed_frame() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "demo photos are camera-sized"); + let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 }; + let job = s.region_job(p.id, w, h, win, true).unwrap(); + assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original"); + let img = job.run().rendered.unwrap().image; + assert_eq!((img.width, img.height), (300, 200)); + // other windows, and the whole-frame job, never share a key + let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap(); + let whole = s.render_job(p.id, w, h, false, true).unwrap(); + let again = s.region_job(p.id, w, h, win, true).unwrap(); + assert_ne!(other.key, again.key); + assert_ne!(whole.key, again.key); + assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key"); + // a window never writes the photo's view preview or the thumbnail cache + assert!(again.view_cache.is_none() && again.cache.is_none()); +} + +// Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview +#[test] +fn a_tight_crop_reads_the_source_level_its_pixels_need() { + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(long > 2560); + let id = p.id; + let mut d = (*s.develop_of(id).unwrap()).clone(); + // the uncropped photo at 1600 px: the preview is plenty + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview); + // a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 }; + s.set_develop(id, d, "Crop").unwrap(); + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full); + // …but with the crop tool open (the whole photo shown) it does not + assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview); + // and a crop shown small enough still reads the preview + assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); +} + +// a window whose spot reads from further away than a render can hold is refused +#[test] +fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize * 8, p.height as usize * 8); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.spots.push(lightcraft_develop::Spot { + points: vec![lightcraft_geom::Point::new(0.2, 0.5)], + size: 0.01, + source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)), + ..Default::default() + }); + s.set_develop(id, d, "Spot").unwrap(); + let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 }; + assert!(s.region_job(id, w, h, win, true).is_none()); + // a window elsewhere is fine + assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); +} + +// Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo +// without its edits (the crop is kept), with a key of its own +#[test] +fn a_before_window_shows_the_unedited_look() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 2.0; + s.set_develop(id, d, "Exposure").unwrap(); + let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 }; + let after = s.region_job(id, w, h, win, true).unwrap(); + let before = s.region_job_before(id, w, h, win, true).unwrap(); + assert_ne!(after.key, before.key); + let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image); + assert_eq!((a.width, a.height), (b.width, b.height)); + let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::() / i.data.len() as f64; + assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); +} + +// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of +// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and +// at a corner, with tone, colour and detail edits on a camera-sized photo +#[test] +fn one_to_one_window_pixels_are_export_pixels() { + use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow}; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}"); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 0.7; + d.light.contrast = 25.0; + d.color.saturation = 20.0; + s.set_develop(id, d, "Edits").unwrap(); + let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; + assert_eq!((export.width, export.height), (w, h), "a full-size export"); + for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] { + let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image; + assert_eq!((tile.width, tile.height), (win.w, win.h)); + let mut worst = 0u8; + for y in 0..win.h { + for x in 0..win.w { + let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]); + for k in 0..3 { + worst = worst.max(a[k].abs_diff(b[k])); + } + } + } + assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels"); + } +} + +/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here +/// one nothing can decode) is not read again. +#[test] +fn a_thumbnail_is_cut_from_the_decoded_preview() { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); + let job = s.thumb_job(p.id, 512).unwrap(); + assert!(matches!(job.source, SourceRef::Fit { .. })); + let r = job.run(); + let thumb = r.rendered.as_ref().unwrap(); + assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); + s.accept(&r); + let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); + assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); +} + +#[test] +fn has_source_follows_what_the_session_accepted() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(!s.media.has_source(id, SourceLevel::Full)); + let r = s.render_job(id, long, long, false, true).unwrap().run(); + s.accept(&r); + assert!(s.media.has_source(id, SourceLevel::Full)); + assert!(!s.media.has_source(id, SourceLevel::Preview)); + assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full)); +} + +#[test] +fn variant_jobs_have_their_own_keys_and_hit_the_cache() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let mine = (*s.develop_of(id).unwrap()).clone(); + let with = |pid: &str| { + let mut d = mine.clone(); + d.profile.id = pid.into(); + d + }; + let (a, b) = (with("lc.vivid"), with("lc.bw.sepia")); + let ja = s.variant_job(id, &a, 128).unwrap(); + let jb = s.variant_job(id, &b, 128).unwrap(); + assert_ne!(ja.key, jb.key, "keys differ per settings"); + assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable"); + assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size"); + assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail"); + assert_eq!(ja.level, SourceLevel::Thumb); + let (cache, ck) = ja.cache.clone().unwrap(); + assert!(cache.get(ck).is_none()); + let r = ja.run(); + s.accept(&r); + let first = r.rendered.unwrap().image; + assert!(first.width.max(first.height) <= 128); + assert!(cache.get(ck).is_some(), "rendered variants are cached"); + assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none()); + // a second job for the same variant is served from the cache, pixel for pixel + let again = s.variant_job(id, &a, 128).unwrap().run(); + assert!(again.loaded.is_none()); + assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes()); + let other = jb.run().rendered.unwrap().image; + assert_ne!(other.as_bytes(), first.as_bytes()); + // the photo's own settings never changed + assert_eq!(*s.develop_of(id).unwrap(), mine); + // hover previews: a loupe-sized job with other settings, its own key + let pv = s.preview_job(id, 800, 600, true, &a).unwrap(); + assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); + assert_eq!(*pv.settings, a); +} diff --git a/crates/engine/src/media/thumbnail_hash_tests.rs b/crates/engine/src/media/thumbnail_hash_tests.rs new file mode 100644 index 000000000..fdbd20f80 --- /dev/null +++ b/crates/engine/src/media/thumbnail_hash_tests.rs @@ -0,0 +1,115 @@ +use super::*; + +fn session() -> crate::Session { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s +} +#[test] +fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() { + let mut s = session(); + for edge in [256, 384, 512, 256] { + let p = s.catalog.photo(PhotoId(1)).unwrap().clone(); + let b = thumb_bucket(edge); + // Independent original formula: never asks the new hash cache for expected values. + let settings = p.develop.hash64(); + let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; + let expected = settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17); + let disk = Hasher128::new() + .str(&content_key(&p)) + .u64(settings) + .u64(b as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish(); + let job = s.thumb_job(p.id, edge).unwrap(); + assert_eq!(job.key, expected); + assert_eq!(job.cache.unwrap().1, disk); + } + s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.render_job(PhotoId(1), 256, 256, true, true).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 1); + let mut edit = DevelopSettings::default(); + edit.light.exposure = 1.0; + let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.thumb_job(PhotoId(1), 384).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 2); + s.catalog.apply(undo).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 3); +} +#[test] +fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() { + let mut hashes = SettingsHashes::default(); + let shared = Arc::new(DevelopSettings::default()); + for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 { + assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64()); + } + assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES); + assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES); + assert_eq!(Arc::strong_count(&shared), 1); + let mut edit = (*shared).clone(); + edit.light.exposure = 1.0; + let edit = Arc::new(edit); + let a = PhotoId(10000); + let b = PhotoId(10001); + let initial = hashes.get(a, &shared); + assert_eq!(hashes.get(b, &shared), initial); + assert_ne!(hashes.get(a, &edit), initial); + assert_eq!(hashes.get(b, &shared), initial); + let mut s = session(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.media.clear_sources(); + assert!(s.media.settings_hashes.entries.is_empty()); + assert!(s.media.settings_hashes.order.is_empty()); +} +#[test] +fn source_from_before_content_reload_is_not_accepted() { + let mut s = session(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default())))); + let old = s.thumb_job(PhotoId(1), 256).unwrap().run(); + assert!(old.loaded.is_some()); + s.catalog + .apply(Op::SetContent { + id: PhotoId(1), + width: 6000, + height: 4000, + file_size: 99, + content_hash: Some("replacement".into()), + preview_only: None, + preview_checked: String::new(), + }) + .unwrap(); + s.media.forget(PhotoId(1)); + s.accept(&old); + assert_eq!(s.media.source_usage().0, 0); +} +/// A panic while rendering (here in the decoder) used to end the pool's worker thread without +/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now, +/// for renders and for stand-ins, answering the request it came from. +#[test] +fn a_render_that_panics_fails_instead_of_never_answering() { + let mut s = session(); + s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + let mut job = s.thumb_job(PhotoId(1), 256).unwrap(); + job.request_id = 42; + let (key, level, source_key) = (job.key, job.level, job.source_key); + let r = job.run(); + assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug")); + let quick = QuickJob { + request_id: 7, + photo: PhotoId(1), + key: 9, + cached: Vec::new(), + embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), + small: None, + }; + let r = quick.run(); + assert_eq!((r.request_id, r.key), (7, 9)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug")); +} diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index b3a140a2b..890e98f81 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -6,17 +6,102 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +#[cfg(not(any(unix, windows)))] +use std::time::SystemTime; use lightcraft_catalog::safe_file::same_file; use lightcraft_catalog::{Catalog, Source}; +/// The directory entry a screenshot is about to write, pinned at request admission. +/// +/// A later write must not replace a path that was absent when the request was admitted (for +/// example a photo imported while the capture was waiting), nor a destination that was replaced by +/// another file in the meantime. [`Session::revalidate_write_target`] checks this immediately +/// before the write, and an absent destination is then published with create-new semantics. +#[derive(Clone, Debug)] +pub struct WriteTarget { + path: String, + state: WriteTargetState, + /// The canonical parent folder at admission, when it existed: a retargeted parent symlink is a + /// different destination even when the file name is unchanged. + parent: Option, +} + +#[derive(Clone, Debug)] +enum WriteTargetState { + /// The directory entry did not exist at admission. + Absent, + /// The directory entry existed; this is its identity (not its contents). + Existing(FileIdentity), +} + +/// Stable identity of a directory entry, from `symlink_metadata` (so replacing a symlink or a +/// hard link is a different entry). Unix uses device + inode; Windows uses the canonical path, +/// length and creation time; other platforms use a canonical/len/mtime fallback. +#[derive(Clone, Debug, PartialEq, Eq)] +struct FileIdentity { + #[cfg(unix)] + dev: u64, + #[cfg(unix)] + ino: u64, + #[cfg(windows)] + canonical: PathBuf, + #[cfg(windows)] + len: u64, + #[cfg(windows)] + created: u64, + #[cfg(not(any(unix, windows)))] + canonical: PathBuf, + #[cfg(not(any(unix, windows)))] + len: u64, + #[cfg(not(any(unix, windows)))] + modified: Option, +} + +impl FileIdentity { + fn of(path: &Path, meta: &std::fs::Metadata) -> Self { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let _ = path; + Self { dev: meta.dev(), ino: meta.ino() } + } + #[cfg(windows)] + { + // The volume serial/file index metadata is still unstable; use stable metadata that + // changes when a different file replaces the entry, and keep the same in-place + // content edits (which do not change the creation time or length) overwritable. + use std::os::windows::fs::MetadataExt; + Self { canonical: std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), len: meta.len(), created: meta.creation_time() } + } + #[cfg(not(any(unix, windows)))] + { + Self { canonical: std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), len: meta.len(), modified: meta.modified().ok() } + } + } +} + +impl WriteTarget { + /// The destination path as the request named it. + pub fn path(&self) -> &str { + &self.path + } + + /// Was the directory entry absent at admission? Such a destination is published with + /// create-new semantics, never with the overwriting writer. + pub fn was_absent(&self) -> bool { + matches!(&self.state, WriteTargetState::Absent) + } +} + /// The library's originals and their sidecars, looked up by file name. #[derive(Debug, Default)] pub struct OriginalGuard { /// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …) by_name: HashMap>, - /// The entries of `by_name` that are symlinks, found on the first check that needs them. - links: std::sync::OnceLock>, + /// Every indexed entry, built on the first check whose target name did not match: a hard + /// link or symlink may name an original differently from the catalogued path. + others: std::sync::OnceLock>, } impl OriginalGuard { @@ -34,7 +119,7 @@ impl OriginalGuard { /// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`). pub fn add(&mut self, path: &Path, label: &str) { let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return }; - self.links = std::sync::OnceLock::new(); + self.others = std::sync::OnceLock::new(); let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what)); put(name.clone(), path.to_path_buf(), format!("the original of {label}")); let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); @@ -44,6 +129,17 @@ impl OriginalGuard { /// `Err` (a message for the user) when writing `target` would replace a protected file. pub fn check(&self, target: &Path) -> Result<(), String> { + self.check_target(target, false) + } + + /// [`Self::check`] plus hard links: a screenshot target must not replace an original reached + /// through a link with a different name. The per-entry identity scan is limited to this + /// long-lived, user-visible destination check, not every ordinary export overwrite. + pub fn check_identity(&self, target: &Path) -> Result<(), String> { + self.check_target(target, true) + } + + fn check_target(&self, target: &Path, hard_links: bool) -> Result<(), String> { if std::fs::symlink_metadata(target).is_err() { return Ok(()); // nothing there to replace } @@ -61,12 +157,15 @@ impl OriginalGuard { } } } - // An imported symlink can have a different name from its original. A write to - // that original would change what the library reads through the link. Only do - // this fallback for existing targets that the name index did not protect; the links are - // looked up once per guard, not once per target. - let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect()); - for (p, what) in links { + // An imported hard link or symlink can have a different name from its original. A write + // to that entry would change what the library reads. Only do this fallback for existing + // targets that the name index did not protect; the list is built once per guard, not once + // per target. Ordinary exports keep the cheaper symlink-only scan. + let others = self.others.get_or_init(|| self.by_name.values().flatten().cloned().collect()); + for (p, what) in others { + if !hard_links && std::fs::read_link(p).is_err() { + continue; + } if same_file(p, target) { return Err(format!( "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", @@ -93,6 +192,69 @@ impl crate::Session { } self.original_guard().check(path) } + + /// Pin the destination of a long-running write (a screenshot) now. + /// + /// The returned [`WriteTarget`] records whether the directory entry existed and, if so, its + /// identity, plus the canonical parent folder. Pass it to [`Self::revalidate_write_target`] + /// immediately before writing. + pub fn pin_write_target(&self, path: &str) -> Result { + let p = Path::new(path); + let parent = p.parent().filter(|d| !d.as_os_str().is_empty()).and_then(|d| std::fs::canonicalize(d).ok()); + match std::fs::symlink_metadata(p) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(WriteTarget { path: path.to_string(), state: WriteTargetState::Absent, parent }) + } + Err(error) => Err(format!("{path}: {error}")), + Ok(meta) => { + // Refuse a catalogued original or sidecar (including a differently named hard + // link) before the request is queued. + self.original_guard().check_identity(p)?; + Ok(WriteTarget { path: path.to_string(), state: WriteTargetState::Existing(FileIdentity::of(p, &meta)), parent }) + } + } + } + + /// Re-check a pinned target immediately before writing. + /// + /// An absent target must still be absent; an existing target must still name the same + /// directory entry and must still pass the current catalog's original guard. A parent folder + /// whose canonical path changed (a retargeted symlink) is refused too. + pub fn revalidate_write_target(&self, target: &WriteTarget) -> Result<(), String> { + if let Some(parent) = &target.parent { + // Resolve the request's parent again: canonicalizing the saved resolved folder + // would miss a directory symlink in the original path being retargeted. + let now = Path::new(&target.path).parent().and_then(|path| std::fs::canonicalize(path).ok()); + match now { + Some(now) if now == *parent => {} + _ => { + return Err(format!( + "{}: the destination folder changed while the screenshot was being captured; refusing to write", + target.path + )); + } + } + } + match std::fs::symlink_metadata(&target.path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => match &target.state { + WriteTargetState::Absent => Ok(()), + WriteTargetState::Existing(_) => { + Err(format!("{}: the file was removed while the screenshot was being captured; refusing to create it again", target.path)) + } + }, + Err(error) => Err(format!("{}: {error}", target.path)), + Ok(meta) => match &target.state { + WriteTargetState::Absent => Err(format!("{} appeared while the screenshot was being captured; refusing to replace it", target.path)), + WriteTargetState::Existing(identity) => { + if FileIdentity::of(Path::new(&target.path), &meta) != *identity { + return Err(format!("{} changed while the screenshot was being captured; refusing to replace the new file", target.path)); + } + // The catalog may have gained this file as an original since admission. + self.original_guard().check_identity(Path::new(&target.path)) + } + }, + } + } } #[cfg(test)] @@ -120,4 +282,61 @@ mod tests { assert!(g.check(&d.join("new.jpg")).is_ok(), "a free name"); let _ = std::fs::remove_dir_all(&d); } + + #[test] + fn write_targets_pin_absent_paths_and_recheck_the_current_library() { + let d = std::env::temp_dir().join(format!("lc-write-targets-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let mut s = crate::Session::new(); + let original = d.join("IMG_1.jpg"); + std::fs::write(&original, b"original").unwrap(); + let photo = Photo::new(PhotoId(1), Source::File { path: original.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + + // A path that was absent at admission is refused when anything appears there. + let free = d.join("new-shot.png"); + let absent = s.pin_write_target(&free.to_string_lossy()).expect("free path"); + assert!(absent.was_absent()); + std::fs::write(&free, b"appeared").unwrap(); + assert!(s.revalidate_write_target(&absent).unwrap_err().contains("appeared")); + std::fs::remove_file(&free).unwrap(); + + // An ordinary existing path can still be overwritten in place (same entry). + let allowed = d.join("old-shot.png"); + std::fs::write(&allowed, b"old").unwrap(); + let existing = s.pin_write_target(&allowed.to_string_lossy()).expect("ordinary file"); + assert!(!existing.was_absent()); + std::fs::write(&allowed, b"new").unwrap(); + assert!(s.revalidate_write_target(&existing).is_ok(), "an allowed screenshot refresh keeps working"); + + // A hard link to an original is refused at admission, and an absent path that becomes + // such a link is refused at write time. + let linked = d.join("linked.png"); + std::fs::hard_link(&original, &linked).unwrap(); + assert!(s.pin_write_target(&linked.to_string_lossy()).unwrap_err().contains("original")); + std::fs::remove_file(&linked).unwrap(); + let appears_as_link = d.join("late-link.png"); + let late = s.pin_write_target(&appears_as_link.to_string_lossy()).expect("free path"); + std::fs::hard_link(&original, &appears_as_link).unwrap(); + assert!(s.revalidate_write_target(&late).unwrap_err().contains("appeared")); + + // A path pinned as an ordinary file becomes an original through a catalog import while + // the capture is queued: the current guard refuses the write. + let imported = d.join("imported.png"); + std::fs::write(&imported, b"not yet imported").unwrap(); + let queued = s.pin_write_target(&imported.to_string_lossy()).expect("ordinary file"); + let p = Photo::new(PhotoId(2), Source::File { path: imported.to_string_lossy().to_string() }, "imported.png", "PNG", 10, 10, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + assert!(s.revalidate_write_target(&queued).unwrap_err().contains("original")); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let link = d.join("link-shot.png"); + symlink(&original, &link).unwrap(); + assert!(s.pin_write_target(&link.to_string_lossy()).unwrap_err().contains("original")); + } + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print.rs similarity index 100% rename from crates/engine/src/print/mod.rs rename to crates/engine/src/print.rs diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index f4e50703b..d8e1eeb87 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -665,432 +665,4 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI } #[cfg(test)] -mod tests { - use super::*; - use crate::print::layout::{GridSpec, Margins}; - - const RED: [u16; 3] = [60000, 1000, 1000]; - const BLUE: [u16; 3] = [1000, 1000, 60000]; - const WHITE: [u16; 3] = [65535; 3]; - - /// A `w` x `h` image whose left half is red and right half blue, with a green top row. - fn halves(w: usize, h: usize) -> Image16 { - let mut img = Image16::filled(w, h, RED).unwrap(); - img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); - img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); - img - } - - fn style() -> CellStyle { - CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } - } - - fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { - img.px(x, y).unwrap() - } - - fn page() -> Image16 { - Image16::filled(100, 100, WHITE).unwrap() - } - - #[test] - fn placement_fit_fill_and_rotation() { - // 4:3 photo into a 2:1 area. - let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); - assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); - let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); - assert_eq!((fill.w, fill.h), (200, 150)); - // A portrait photo in a landscape area: turning prints it larger. - assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); - let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); - assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); - assert_eq!(turned.unturned(), (300, 400)); - // Filling a landscape cell with a portrait photo crops less when turned. - let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); - assert!(f.rotated && f.w >= 400 && f.h >= 300); - // Matching orientation never turns. - assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); - assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); - // Hostile input. - for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { - assert!(place(bad, 10.0, 10, 10, false, false).is_none()); - assert!(place(10.0, bad, 10, 10, true, true).is_none()); - } - assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); - } - - #[test] - fn fit_centres_the_photo() { - let mut p = page(); - draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); - // 40x20 scaled to 80x40, centred vertically at y 30..70. - assert_eq!(at(&p, 10, 50), RED); - assert_eq!(at(&p, 89, 50), BLUE); - assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); - assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); - assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); - } - - #[test] - fn zoom_to_fill_crops_the_centre() { - let mut p = page(); - let s = CellStyle { fill: true, ..style() }; - draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); - // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. - for y in [10, 50, 89] { - assert_ne!(at(&p, 10, y), WHITE); - assert_ne!(at(&p, 89, y), WHITE); - } - assert_eq!(at(&p, 20, 50), RED); - assert_eq!(at(&p, 80, 50), BLUE); - // Nothing leaks outside the cell. - assert_eq!(at(&p, 9, 50), WHITE); - assert_eq!(at(&p, 90, 50), WHITE); - assert_eq!(at(&p, 50, 9), WHITE); - assert_eq!(at(&p, 50, 90), WHITE); - } - - #[test] - fn rotate_to_fit_turns_clockwise() { - // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. - let mut p = page(); - let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; - draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); - assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); - assert_eq!(at(&p, 45, 30), RED); - let mut q = page(); - draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); - // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. - assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); - assert_eq!(at(&q, 12, 48), BLUE); - assert_eq!(at(&q, 50, 20), RED); - assert_eq!(at(&q, 50, 40), BLUE); - assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); - } - - #[test] - fn border_frames_the_photo_inside_the_cell() { - let mut p = page(); - let green = [100, 60000, 100]; - let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; - draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); - assert_eq!(at(&p, 10, 40), green); - assert_eq!(at(&p, 14, 40), green); - assert_eq!(at(&p, 15, 40), RED); - assert_eq!(at(&p, 69, 40), green); - assert_eq!(at(&p, 64, 40), BLUE); - assert_eq!(at(&p, 40, 10), green); - assert_eq!(at(&p, 40, 69), green); - // A fitted photo gets the border hugging it, not the whole cell. - let mut q = page(); - let s = CellStyle { border_px: 5, border: green, ..style() }; - draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); - // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. - assert_eq!(at(&q, 50, 23), green); - assert_eq!(at(&q, 50, 22), green); - assert_eq!(at(&q, 50, 21), WHITE); - // A border wider than the cell leaves the photo visible. - let mut r = page(); - let s = CellStyle { border_px: 500, border: green, ..style() }; - draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); - assert_ne!(at(&r, 20, 20), WHITE); - } - - #[test] - fn stroke_is_drawn_inside_the_photo_edge() { - let mut p = page(); - let black = [0, 0, 0]; - let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; - draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); - for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { - assert_eq!(at(&p, x, y), black, "({x}, {y})"); - } - assert_eq!(at(&p, 23, 40), RED); - assert_eq!(at(&p, 76, 40), BLUE); - assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); - // Corners are covered exactly once and a huge stroke stays within the photo. - let mut q = page(); - let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; - draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); - assert_eq!(at(&q, 40, 40), black); - assert_eq!(at(&q, 19, 40), WHITE); - assert_eq!(at(&q, 60, 40), WHITE); - } - - #[test] - fn colours_are_rec2020_encoded() { - assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); - assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); - let grey = rec2020_from_srgb8([128; 3]); - assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); - // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. - let red = rec2020_from_srgb8([255, 0, 0]); - assert!(red[0] < 65535 && red[1] > 0); - } - - #[test] - fn resize_preserves_flat_colour_and_edges() { - let img = halves(64, 32); - for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { - let r = img.resized(w, h).unwrap(); - assert_eq!((r.w, r.h), (w, h)); - assert_eq!(r.rgb.len(), w * h * 3); - } - let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); - assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); - assert!(img.resized(0, 5).is_err()); - let r = img.resized(32, 16).unwrap(); - assert_eq!(at(&r, 4, 8), RED); - assert_eq!(at(&r, 28, 8), BLUE); - } - - #[test] - fn rotate_and_blit_are_clipped() { - let img = halves(3, 2); - 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); - assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); - p.blit(&img, 5, 5, 0, 0, 3, 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); - } - - fn settings(style: Style) -> PrintSettings { - let mut s = PrintSettings::default(); - s.layout.style = style; - s.layout.page.paper = "4x6".into(); - s.layout.page.margins = Margins::uniform(0.5); - s.job.ppi = 100.0; - s - } - - #[test] - fn plans_follow_the_layout_style() { - let ids: Vec = (1..=5).map(PhotoId).collect(); - let mut s = settings(Style::ContactSheet); - s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; - let plan = plan_cells(&s.layout, &ids).unwrap(); - assert_eq!(plan.iter().map(Vec::len).collect::>(), vec![4, 1]); - assert_eq!(plan[1][0].1, PhotoId(5)); - let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); - assert_eq!(one.len(), 5); - assert!(plan_cells(&s.layout, &[]).is_err()); - assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); - - let mut pkg = settings(Style::PicturePackage); - assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); - pkg.layout.add_cell(1.0, 1.0).unwrap(); - pkg.layout.add_cell(2.0, 2.0).unwrap(); - let plan = plan_cells(&pkg.layout, &ids).unwrap(); - assert_eq!(plan.len(), 1); - assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); - - let mut custom = settings(Style::CustomPackage); - let a = custom.layout.add_cell(1.0, 1.0).unwrap(); - custom.layout.add_cell(1.0, 1.0).unwrap(); - custom.layout.set_cell_photo(a, Some(3)).unwrap(); - let plan = plan_cells(&custom.layout, &ids).unwrap(); - assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); - let blank_page = { - custom.layout.new_page().unwrap(); - plan_cells(&custom.layout, &ids).unwrap() - }; - assert_eq!(blank_page.iter().map(Vec::len).collect::>(), vec![2, 0]); - } - - fn first_ids(s: &Session, n: usize) -> Vec { - s.catalog.photos().take(n).map(|p| p.id).collect() - } - - #[test] - fn composes_pages_from_real_photos() { - let mut s = Session::with_demo(); - let ids = first_ids(&s, 3); - let mut set = settings(Style::ContactSheet); - set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; - set.page.background = [200, 30, 30]; - set.image.zoom_to_fill = true; - set.image.stroke_enabled = true; - set.image.stroke_width_pt = 2.0; - set.image.stroke_color = [0, 0, 0]; - let work = prepare(&mut s, &set, &ids).unwrap(); - assert_eq!((work.len(), work.pages()), (3, 2)); - let mut steps = Vec::new(); - let pages = work - .run(&mut |i, name| { - steps.push((i, name.to_string())); - true - }) - .unwrap(); - assert_eq!(steps.iter().map(|s| s.0).collect::>(), vec![0, 1, 2, 3]); - assert_eq!(pages.len(), 2); - let first = &pages[0]; - assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); - assert_eq!(first.rgb.len(), 400 * 600 * 3); - let bg = rec2020_from_srgb8([200, 30, 30]); - let px = |p: &PageRaster, x: usize, y: usize| { - let i = (y * p.width as usize + x) * 3; - [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] - }; - assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); - assert_eq!(px(first, 395, 595), bg); - // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. - assert_eq!(px(first, 50, 50), [0, 0, 0]); - let centre = px(first, 200, 150); - assert_ne!(centre, bg); - assert_ne!(centre, [0, 0, 0]); - // The second page holds the third photo in the first cell only: the second cell is bare. - let second = &pages[1]; - assert_ne!(px(second, 200, 150), bg); - assert_eq!(px(second, 200, 450), bg); - // Cancelling gives no pages. - let work = prepare(&mut s, &set, &ids).unwrap(); - assert!(work.run(&mut |i, _| i < 1).is_err()); - } - - #[test] - fn package_renders_each_size_and_border() { - let mut s = Session::with_demo(); - let ids = first_ids(&s, 1); - let mut set = settings(Style::PicturePackage); - set.layout.add_cell(1.5, 1.0).unwrap(); - set.layout.add_cell(1.5, 1.0).unwrap(); - set.image.border_enabled = true; - set.image.border_width = 0.1; - set.image.border_color = [10, 200, 10]; - let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); - assert_eq!(pages.len(), 1); - let green = rec2020_from_srgb8([10, 200, 10]); - let cell = set.layout.cells[0].rect; - let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); - let at = |x: usize, y: usize| { - let i = (y * 400 + x) * 3; - pages[0].rgb[i..i + 3].to_vec() - }; - // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. - let mid = (x0 + 75, y0 + 50); - assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); - let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); - assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); - } - - /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the - /// margins are bare background unless something draws there). - fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec { - let ids = first_ids(s, 2); - let mut set = settings(style); - if style.is_package() { - set.layout.add_cell(3.0, 5.0).unwrap(); - set.layout.new_page().unwrap(); - let id = set.layout.add_cell(3.0, 5.0).unwrap(); - set.layout.set_cell_page(id, 1).unwrap(); - } - set.image.zoom_to_fill = true; - set.page = page; - prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() - } - - fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { - let i = (y * p.width as usize + x) * 3; - [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] - } - - /// The pixels of `x0..x1` x `y0..y1` that are not the white background. - fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() - } - - #[test] - fn crop_marks_sit_outside_each_photo_corner() { - let mut s = Session::with_demo(); - let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); - assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); - let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); - // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, - // along each edge of the photo. - for p in &pages { - for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { - assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); - } - for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { - assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); - } - } - // a dark page gets white marks - let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); - assert_eq!(rgb(&dark[0], 30, 50), WHITE); - } - - #[test] - fn page_numbers_and_info_print_in_the_bottom_margin() { - let mut s = Session::with_demo(); - let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); - // bottom right: the number, different on each page; nothing at the bottom left - let (right, left) = ((200, 400), (0, 200)); - for p in &numbered { - assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); - assert_eq!(inked(p, left.0, left.1, 550, 600), 0); - assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); - } - let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>(); - assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); - let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); - for p in &info { - assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); - } - // the packages have no Page Options in Lightroom: nothing is drawn - let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; - let package = page_options_pages(&mut s, Style::PicturePackage, all); - assert_eq!(package.len(), 2); - for p in &package { - assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); - } - } - - #[test] - fn page_info_names_the_sharpening_profile_and_resolution() { - use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; - let mut set = PrintSettings::default(); - let line = page_info_text(&set); - assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); - set.job.color.sharpen = PrintSharpen::Off; - set.job.color.media = PrintMedia::Matte; - set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; - set.job.ppi = 300.0; - assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); - set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; - assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); - } - - #[test] - fn caps_and_hostile_settings_are_refused() { - let mut s = Session::with_demo(); - let ids = first_ids(&s, 1); - let mut set = settings(Style::SingleImage); - set.layout.page.paper = "custom".into(); - set.layout.page.custom_width = 60.0; - set.layout.page.custom_height = 60.0; - set.job.ppi = 720.0; - assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); - let mut set = settings(Style::SingleImage); - set.job.ppi = f32::NAN; - assert!(prepare(&mut s, &set, &ids).is_err()); - let mut set = settings(Style::SingleImage); - set.image.border_width = f64::NAN; - assert!(prepare(&mut s, &set, &ids).is_err()); - set.image.border_width = 0.1; - set.image.stroke_width_pt = -1.0; - assert!(prepare(&mut s, &set, &ids).is_err()); - assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); - assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); - let set = settings(Style::SingleImage); - assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); - // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. - let mut big = settings(Style::SingleImage); - big.job.ppi = 720.0; - assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); - } -} +mod tests; diff --git a/crates/engine/src/print/compose/tests.rs b/crates/engine/src/print/compose/tests.rs new file mode 100644 index 000000000..cdec92799 --- /dev/null +++ b/crates/engine/src/print/compose/tests.rs @@ -0,0 +1,427 @@ +use super::*; +use crate::print::layout::{GridSpec, Margins}; + +const RED: [u16; 3] = [60000, 1000, 1000]; +const BLUE: [u16; 3] = [1000, 1000, 60000]; +const WHITE: [u16; 3] = [65535; 3]; + +/// A `w` x `h` image whose left half is red and right half blue, with a green top row. +fn halves(w: usize, h: usize) -> Image16 { + let mut img = Image16::filled(w, h, RED).unwrap(); + img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); + img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); + img +} + +fn style() -> CellStyle { + CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } +} + +fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { + img.px(x, y).unwrap() +} + +fn page() -> Image16 { + Image16::filled(100, 100, WHITE).unwrap() +} + +#[test] +fn placement_fit_fill_and_rotation() { + // 4:3 photo into a 2:1 area. + let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); + assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); + let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); + assert_eq!((fill.w, fill.h), (200, 150)); + // A portrait photo in a landscape area: turning prints it larger. + assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); + let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); + assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); + assert_eq!(turned.unturned(), (300, 400)); + // Filling a landscape cell with a portrait photo crops less when turned. + let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); + assert!(f.rotated && f.w >= 400 && f.h >= 300); + // Matching orientation never turns. + assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); + assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); + // Hostile input. + for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { + assert!(place(bad, 10.0, 10, 10, false, false).is_none()); + assert!(place(10.0, bad, 10, 10, true, true).is_none()); + } + assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); +} + +#[test] +fn fit_centres_the_photo() { + let mut p = page(); + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); + // 40x20 scaled to 80x40, centred vertically at y 30..70. + assert_eq!(at(&p, 10, 50), RED); + assert_eq!(at(&p, 89, 50), BLUE); + assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); + assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); + assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); +} + +#[test] +fn zoom_to_fill_crops_the_centre() { + let mut p = page(); + let s = CellStyle { fill: true, ..style() }; + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); + // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. + for y in [10, 50, 89] { + assert_ne!(at(&p, 10, y), WHITE); + assert_ne!(at(&p, 89, y), WHITE); + } + assert_eq!(at(&p, 20, 50), RED); + assert_eq!(at(&p, 80, 50), BLUE); + // Nothing leaks outside the cell. + assert_eq!(at(&p, 9, 50), WHITE); + assert_eq!(at(&p, 90, 50), WHITE); + assert_eq!(at(&p, 50, 9), WHITE); + assert_eq!(at(&p, 50, 90), WHITE); +} + +#[test] +fn rotate_to_fit_turns_clockwise() { + // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. + let mut p = page(); + let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; + draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); + assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); + assert_eq!(at(&p, 45, 30), RED); + let mut q = page(); + draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); + // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. + assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); + assert_eq!(at(&q, 12, 48), BLUE); + assert_eq!(at(&q, 50, 20), RED); + assert_eq!(at(&q, 50, 40), BLUE); + assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); +} + +#[test] +fn border_frames_the_photo_inside_the_cell() { + let mut p = page(); + let green = [100, 60000, 100]; + let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); + assert_eq!(at(&p, 10, 40), green); + assert_eq!(at(&p, 14, 40), green); + assert_eq!(at(&p, 15, 40), RED); + assert_eq!(at(&p, 69, 40), green); + assert_eq!(at(&p, 64, 40), BLUE); + assert_eq!(at(&p, 40, 10), green); + assert_eq!(at(&p, 40, 69), green); + // A fitted photo gets the border hugging it, not the whole cell. + let mut q = page(); + let s = CellStyle { border_px: 5, border: green, ..style() }; + draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); + // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. + assert_eq!(at(&q, 50, 23), green); + assert_eq!(at(&q, 50, 22), green); + assert_eq!(at(&q, 50, 21), WHITE); + // A border wider than the cell leaves the photo visible. + let mut r = page(); + let s = CellStyle { border_px: 500, border: green, ..style() }; + draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); + assert_ne!(at(&r, 20, 20), WHITE); +} + +#[test] +fn stroke_is_drawn_inside_the_photo_edge() { + let mut p = page(); + let black = [0, 0, 0]; + let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); + for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { + assert_eq!(at(&p, x, y), black, "({x}, {y})"); + } + assert_eq!(at(&p, 23, 40), RED); + assert_eq!(at(&p, 76, 40), BLUE); + assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); + // Corners are covered exactly once and a huge stroke stays within the photo. + let mut q = page(); + let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; + draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); + assert_eq!(at(&q, 40, 40), black); + assert_eq!(at(&q, 19, 40), WHITE); + assert_eq!(at(&q, 60, 40), WHITE); +} + +#[test] +fn colours_are_rec2020_encoded() { + assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); + assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); + let grey = rec2020_from_srgb8([128; 3]); + assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); + // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. + let red = rec2020_from_srgb8([255, 0, 0]); + assert!(red[0] < 65535 && red[1] > 0); +} + +#[test] +fn resize_preserves_flat_colour_and_edges() { + let img = halves(64, 32); + for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { + let r = img.resized(w, h).unwrap(); + assert_eq!((r.w, r.h), (w, h)); + assert_eq!(r.rgb.len(), w * h * 3); + } + let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); + assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); + assert!(img.resized(0, 5).is_err()); + let r = img.resized(32, 16).unwrap(); + assert_eq!(at(&r, 4, 8), RED); + assert_eq!(at(&r, 28, 8), BLUE); +} + +#[test] +fn rotate_and_blit_are_clipped() { + let img = halves(3, 2); + 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); + assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); + p.blit(&img, 5, 5, 0, 0, 3, 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); +} + +fn settings(style: Style) -> PrintSettings { + let mut s = PrintSettings::default(); + s.layout.style = style; + s.layout.page.paper = "4x6".into(); + s.layout.page.margins = Margins::uniform(0.5); + s.job.ppi = 100.0; + s +} + +#[test] +fn plans_follow_the_layout_style() { + let ids: Vec = (1..=5).map(PhotoId).collect(); + let mut s = settings(Style::ContactSheet); + s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; + let plan = plan_cells(&s.layout, &ids).unwrap(); + assert_eq!(plan.iter().map(Vec::len).collect::>(), vec![4, 1]); + assert_eq!(plan[1][0].1, PhotoId(5)); + let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); + assert_eq!(one.len(), 5); + assert!(plan_cells(&s.layout, &[]).is_err()); + assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); + + let mut pkg = settings(Style::PicturePackage); + assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); + pkg.layout.add_cell(1.0, 1.0).unwrap(); + pkg.layout.add_cell(2.0, 2.0).unwrap(); + let plan = plan_cells(&pkg.layout, &ids).unwrap(); + assert_eq!(plan.len(), 1); + assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); + + let mut custom = settings(Style::CustomPackage); + let a = custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.set_cell_photo(a, Some(3)).unwrap(); + let plan = plan_cells(&custom.layout, &ids).unwrap(); + assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); + let blank_page = { + custom.layout.new_page().unwrap(); + plan_cells(&custom.layout, &ids).unwrap() + }; + assert_eq!(blank_page.iter().map(Vec::len).collect::>(), vec![2, 0]); +} + +fn first_ids(s: &Session, n: usize) -> Vec { + s.catalog.photos().take(n).map(|p| p.id).collect() +} + +#[test] +fn composes_pages_from_real_photos() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 3); + let mut set = settings(Style::ContactSheet); + set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; + set.page.background = [200, 30, 30]; + set.image.zoom_to_fill = true; + set.image.stroke_enabled = true; + set.image.stroke_width_pt = 2.0; + set.image.stroke_color = [0, 0, 0]; + let work = prepare(&mut s, &set, &ids).unwrap(); + assert_eq!((work.len(), work.pages()), (3, 2)); + let mut steps = Vec::new(); + let pages = work + .run(&mut |i, name| { + steps.push((i, name.to_string())); + true + }) + .unwrap(); + assert_eq!(steps.iter().map(|s| s.0).collect::>(), vec![0, 1, 2, 3]); + assert_eq!(pages.len(), 2); + let first = &pages[0]; + assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); + assert_eq!(first.rgb.len(), 400 * 600 * 3); + let bg = rec2020_from_srgb8([200, 30, 30]); + let px = |p: &PageRaster, x: usize, y: usize| { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + }; + assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); + assert_eq!(px(first, 395, 595), bg); + // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. + assert_eq!(px(first, 50, 50), [0, 0, 0]); + let centre = px(first, 200, 150); + assert_ne!(centre, bg); + assert_ne!(centre, [0, 0, 0]); + // The second page holds the third photo in the first cell only: the second cell is bare. + let second = &pages[1]; + assert_ne!(px(second, 200, 150), bg); + assert_eq!(px(second, 200, 450), bg); + // Cancelling gives no pages. + let work = prepare(&mut s, &set, &ids).unwrap(); + assert!(work.run(&mut |i, _| i < 1).is_err()); +} + +#[test] +fn package_renders_each_size_and_border() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::PicturePackage); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.image.border_enabled = true; + set.image.border_width = 0.1; + set.image.border_color = [10, 200, 10]; + let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); + assert_eq!(pages.len(), 1); + let green = rec2020_from_srgb8([10, 200, 10]); + let cell = set.layout.cells[0].rect; + let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); + let at = |x: usize, y: usize| { + let i = (y * 400 + x) * 3; + pages[0].rgb[i..i + 3].to_vec() + }; + // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. + let mid = (x0 + 75, y0 + 50); + assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); + let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); + assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); +} + +/// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the +/// margins are bare background unless something draws there). +fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec { + let ids = first_ids(s, 2); + let mut set = settings(style); + if style.is_package() { + set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.new_page().unwrap(); + let id = set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.set_cell_page(id, 1).unwrap(); + } + set.image.zoom_to_fill = true; + set.page = page; + prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() +} + +fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] +} + +/// The pixels of `x0..x1` x `y0..y1` that are not the white background. +fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() +} + +#[test] +fn crop_marks_sit_outside_each_photo_corner() { + let mut s = Session::with_demo(); + let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); + assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); + let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); + // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, + // along each edge of the photo. + for p in &pages { + for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { + assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); + } + for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { + assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); + } + } + // a dark page gets white marks + let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); + assert_eq!(rgb(&dark[0], 30, 50), WHITE); +} + +#[test] +fn page_numbers_and_info_print_in_the_bottom_margin() { + let mut s = Session::with_demo(); + let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); + // bottom right: the number, different on each page; nothing at the bottom left + let (right, left) = ((200, 400), (0, 200)); + for p in &numbered { + assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); + assert_eq!(inked(p, left.0, left.1, 550, 600), 0); + assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); + } + let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>(); + assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); + let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); + for p in &info { + assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); + } + // the packages have no Page Options in Lightroom: nothing is drawn + let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; + let package = page_options_pages(&mut s, Style::PicturePackage, all); + assert_eq!(package.len(), 2); + for p in &package { + assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); + } +} + +#[test] +fn page_info_names_the_sharpening_profile_and_resolution() { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let mut set = PrintSettings::default(); + let line = page_info_text(&set); + assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); + set.job.color.sharpen = PrintSharpen::Off; + set.job.color.media = PrintMedia::Matte; + set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; + set.job.ppi = 300.0; + assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); + set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; + assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); +} + +#[test] +fn caps_and_hostile_settings_are_refused() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::SingleImage); + set.layout.page.paper = "custom".into(); + set.layout.page.custom_width = 60.0; + set.layout.page.custom_height = 60.0; + set.job.ppi = 720.0; + assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); + let mut set = settings(Style::SingleImage); + set.job.ppi = f32::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + let mut set = settings(Style::SingleImage); + set.image.border_width = f64::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + set.image.border_width = 0.1; + set.image.stroke_width_pt = -1.0; + assert!(prepare(&mut s, &set, &ids).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); + let set = settings(Style::SingleImage); + assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); + // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. + let mut big = settings(Style::SingleImage); + big.job.ppi = 720.0; + assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); +} diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs index be4438424..046ff952c 100644 --- a/crates/engine/src/print/layout.rs +++ b/crates/engine/src/print/layout.rs @@ -961,495 +961,4 @@ impl Layout { } #[cfg(test)] -mod tests { - use super::*; - - fn package() -> Layout { - Layout { style: Style::PicturePackage, ..Default::default() } - } - - fn assert_clean(l: &Layout) { - let area = l.printable().unwrap(); - for c in &l.cells { - assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); - assert!(c.page < l.pages); - } - assert!(!l.has_overlaps(), "{:#?}", l.cells); - } - - #[test] - fn papers_and_orientation() { - let mut p = PageSetup::default(); - assert_eq!(p.size().unwrap(), (8.5, 11.0)); - p.orientation = Orientation::Landscape; - assert_eq!(p.size().unwrap(), (11.0, 8.5)); - assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); - p.paper = "A4".into(); - let (w, h) = p.size().unwrap(); - assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); - for n in paper_names() { - p.paper = n.into(); - assert!(p.printable().is_ok(), "{n}"); - } - p.paper = "custom".into(); - p.custom_width = 20.0; - p.custom_height = 30.0; - assert_eq!(p.size().unwrap(), (30.0, 20.0)); - p.paper = "poster".into(); - assert!(p.size().is_err()); - } - - /// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as - /// aliases, and the ISO/US papers around them unchanged. - #[test] - fn japanese_photo_and_postcard_papers() { - let pt = |name: &str| { - let p = PageSetup { paper: name.into(), ..Default::default() }; - p.size_points().unwrap_or_else(|e| panic!("{name}: {e}")) - }; - for (name, w_mm, h_mm) in [ - ("L判", 89.0, 127.0), - ("2L判", 127.0, 178.0), - ("KG判", 102.0, 152.0), - ("はがき", 100.0, 148.0), - ("往復はがき", 148.0, 200.0), - ("六切", 203.0, 254.0), - ("四切", 254.0, 305.0), - ("名刺", 55.0, 91.0), - ] { - let (w, h) = pt(name); - let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0); - assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}"); - assert!((w - h).abs() > 1e-6, "{name} is portrait"); - } - // The customary and English spellings resolve to the same paper, including spaces and case. - for (a, b) in [ - ("L判", "l"), - ("L判", " L "), - ("L判", "L-size"), - ("2L判", "2l"), - ("KG判", "kg"), - ("はがき", "postcard"), - ("はがき", "ハガキ"), - ("往復はがき", "double-postcard"), - ("六切", "6cut"), - ("六切", "6切"), - ("四切", "4cut"), - ("名刺", "business-card"), - ("A4", "a 4"), - ("4 × 6", "4x6"), - ] { - assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}"); - } - // Punctuation is ignored, including the periods and Japanese marks a pasted name may carry. - for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")] - { - assert_eq!(paper_name(name), Some(canonical), "{name}"); - } - assert_eq!(paper_name("custom"), None); - assert!(paper_size("poster").is_none()); - // The papers that were already there keep their sizes. - assert_eq!(pt("letter"), (612.0, 792.0)); - assert_eq!(pt("4x6"), (288.0, 432.0)); - assert_eq!(pt("5x7"), (360.0, 504.0)); - assert_eq!(pt("8x10"), (576.0, 720.0)); - assert_eq!(pt("13x19"), (936.0, 1368.0)); - let (w, h) = pt("A4"); - assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}"); - } - - #[test] - fn hostile_numbers_are_rejected() { - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { - let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; - assert!(p.size().is_err(), "{bad}"); - let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; - assert!(p.printable().is_err(), "{bad}"); - let mut l = package(); - assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); - assert!(l.add_cell(2.0, bad).is_err()); - let id = l.add_cell(2.0, 2.0).unwrap(); - assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); - assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); - assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); - let mut g = Layout { spacing: bad, ..package() }; - assert!(g.validate().is_err()); - assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); - assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); - } - // Margins that leave nothing. - let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; - assert!(p.printable().is_err()); - let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; - assert!(l.validate().is_err()); - l.grid.columns = 21; - assert!(l.validate().is_err()); - } - - #[test] - fn units_convert() { - for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { - assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); - } - assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); - assert_eq!(Unit::Points.from_inches(2.0), 144.0); - assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); - } - - #[test] - fn contact_grid_fills_the_printable_area() { - let l = Layout { - style: Style::ContactSheet, - grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, - ..Default::default() - }; - let area = l.printable().unwrap(); - let cells = l.grid_rects().unwrap(); - assert_eq!(cells.len(), 12); - let first = cells.first().unwrap(); - let last = cells.last().unwrap(); - assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); - assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); - for (i, a) in cells.iter().enumerate() { - assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); - for b in cells.iter().skip(i + 1) { - assert!(!a.overlaps(b)); - } - } - // Square cells are centred. - let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; - let cells = sq.grid_rects().unwrap(); - assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); - let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); - assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); - // Single image ignores the grid. - let one = Layout { style: Style::SingleImage, ..l }; - assert_eq!(one.grid_rects().unwrap(), vec![area]); - } - - #[test] - fn page_counts() { - let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; - assert_eq!(l.page_count(0), 1); - assert_eq!(l.page_count(4), 1); - assert_eq!(l.page_count(5), 2); - l.style = Style::SingleImage; - assert_eq!(l.page_count(3), 3); - l.style = Style::PicturePackage; - l.pages = 4; - assert_eq!(l.page_count(99), 4); - } - - #[test] - fn packer_is_deterministic_and_clean() { - let area = Layout::default().printable().unwrap(); - let sizes: Vec<(f64, f64)> = - [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] - .to_vec(); - let a = pack(&sizes, area, 0.1, true).unwrap(); - assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); - assert_eq!(a.len(), sizes.len()); - for (i, (p, r, turned)) in a.iter().enumerate() { - assert!(area.contains(r), "{r:?}"); - let (w, h) = sizes[i]; - let expect = if *turned { (h, w) } else { (w, h) }; - assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); - for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { - if p == q { - // The spacing is kept between neighbours. - let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); - assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); - } - } - } - // The input order does not hide a block that needs a page of its own. - assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); - } - - #[test] - fn packer_rotates_only_when_needed_and_refuses_oversize() { - let area = Rect::new(0.25, 0.25, 8.0, 10.5); - // 10 x 5 only fits a portrait page turned. - let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); - assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); - assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); - let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); - assert!(!r[0].2, "a block that fits as given is not turned"); - assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); - // A block that exactly fills the area fits despite the spacing. - assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); - assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); - // More pages than allowed. - assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); - assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); - } - - #[test] - fn packing_is_dense() { - // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. - let area = Rect::new(0.25, 0.25, 8.0, 10.5); - let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); - assert!(r.iter().all(|p| p.0 == 0)); - // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). - let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); - assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); - } - - #[test] - fn add_cell_fills_gaps_then_opens_pages() { - let mut l = package(); - let a = l.add_cell(3.9, 6.0).unwrap(); - let b = l.add_cell(3.9, 6.0).unwrap(); - assert_eq!(l.cell(a).unwrap().rect.x, 0.25); - assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); - assert_clean(&l); - // Keep adding until a second page appears. - for _ in 0..6 { - l.add_cell(3.9, 6.0).unwrap(); - } - assert!(l.pages >= 2); - assert_clean(&l); - assert_eq!(l.cells.len(), 8); - assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); - let mut grid = Layout::default(); - assert!(grid.add_cell(1.0, 1.0).is_err()); - } - - #[test] - fn cell_cap() { - let mut l = package(); - for _ in 0..MAX_CELLS { - if l.add_cell(0.5, 0.5).is_err() { - break; - } - } - assert!(l.cells.len() <= MAX_CELLS); - let n = l.cells.len(); - if n == MAX_CELLS { - assert!(l.add_cell(0.5, 0.5).is_err()); - } - } - - #[test] - fn move_and_resize_stay_inside() { - let mut l = package(); - let id = l.add_cell(2.0, 3.0).unwrap(); - let r = l.move_cell(id, 100.0, -5.0).unwrap(); - let area = l.printable().unwrap(); - assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); - let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); - assert!(area.contains(&r)); - assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); - assert!(l.move_cell(77, 0.0, 0.0).is_err()); - l.delete_cell(id).unwrap(); - assert!(l.delete_cell(id).is_err()); - } - - #[test] - fn stacking_order() { - let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; - let ids: Vec = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); - let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::>(); - assert_eq!(order(&l), ids); - l.restack(ids[0], Stack::ToFront).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); - l.restack(ids[0], Stack::Backward).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); - l.restack(ids[0], Stack::Forward).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); - l.restack(ids[0], Stack::Forward).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); - l.restack(ids[0], Stack::ToBack).unwrap(); - assert_eq!(order(&l), ids); - l.restack(ids[0], Stack::Backward).unwrap(); - assert_eq!(order(&l), ids); - assert!(l.restack(99, Stack::ToBack).is_err()); - } - - #[test] - fn stacking_ignores_other_pages() { - let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; - let a = l.add_cell(1.0, 1.0).unwrap(); - let b = l.add_cell(1.0, 1.0).unwrap(); - let c = l.add_cell(1.0, 1.0).unwrap(); - l.set_cell_page(b, 1).unwrap(); - l.restack(a, Stack::ToFront).unwrap(); - // `a` goes past `c` (same page) and not necessarily past `b`. - let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); - assert!(pos(a) > pos(c)); - } - - #[test] - fn overlap_is_allowed_by_move_and_reported() { - let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; - let a = l.add_cell(3.0, 3.0).unwrap(); - let b = l.add_cell(3.0, 3.0).unwrap(); - assert!(!l.has_overlaps()); - let r = l.cell(a).unwrap().rect; - l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); - assert!(l.has_overlaps()); - // Touching edges are not an overlap. - l.move_cell(b, r.right(), r.y).unwrap(); - assert!(!l.has_overlaps()); - } - - #[test] - fn auto_layout_repacks_cleanly_and_keeps_photos() { - let mut l = package(); - for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { - l.add_cell(w, h).unwrap(); - } - let first = l.cells[0].id; - l.set_cell_photo(first, Some(42)).unwrap(); - // Scramble positions to overlap, then auto layout. - for c in &mut l.cells { - c.rect.x = 0.25; - c.rect.y = 0.25; - c.page = 0; - } - l.pages = 1; - assert!(l.has_overlaps()); - l.auto_layout().unwrap(); - assert_clean(&l); - assert_eq!(l.cell(first).unwrap().photo, Some(42)); - assert_eq!(l.cells.len(), 6); - let again = l.clone(); - let mut l2 = again.clone(); - l2.auto_layout().unwrap(); - l.auto_layout().unwrap(); - assert_eq!(l, l2); - } - - #[test] - fn auto_layout_failure_changes_nothing() { - let mut l = package(); - l.add_cell(2.0, 2.0).unwrap(); - l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); - let before = l.clone(); - assert!(l.auto_layout().is_err()); - assert_eq!(l, before); - } - - #[test] - fn clear_and_new_page() { - let mut l = package(); - l.add_cell(2.0, 2.0).unwrap(); - assert_eq!(l.new_page().unwrap(), 1); - assert_eq!(l.pages, 2); - l.clear(); - assert_eq!((l.cells.len(), l.pages), (0, 1)); - for _ in 1..MAX_PAGES { - l.new_page().unwrap(); - } - assert!(l.new_page().is_err()); - let mut g = Layout::default(); - assert!(g.new_page().is_err()); - } - - #[test] - fn validate_catches_bad_cells() { - let mut l = package(); - l.add_cell(2.0, 2.0).unwrap(); - let ok = l.clone(); - let mut bad = ok.clone(); - bad.cells[0].rect.w = f64::NAN; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.cells[0].page = 3; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.cells[0].rect.x = 50.0; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.cells.push(bad.cells[0].clone()); - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.pages = 0; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.next_id = 0; - bad.validate().unwrap(); - assert!(bad.next_id > bad.cells[0].id); - assert!(serde_json::from_str::(r#"{"surprise":1}"#).is_err()); - let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); - round.validate().unwrap(); - assert_eq!(round, ok); - } - - #[test] - fn snapping_to_cells_grid_and_margins() { - let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; - let a = l.add_cell(3.0, 3.0).unwrap(); - let b = l.add_cell(2.0, 2.0).unwrap(); - let ra = l.cell(a).unwrap().rect; - // Left edge of b near the right edge of a. - let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); - assert!((m.x - ra.right()).abs() < 1e-9); - assert_eq!(m.y, 5.0); - // Too far: untouched. - let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); - assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); - // Right edge snaps to the printable margin. - let area = l.printable().unwrap(); - let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); - assert!((m.right() - area.right()).abs() < 1e-9); - // Off leaves everything. - let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); - assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); - // Grid snapping lands on multiples from the printable corner. - let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); - assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); - // Resize snaps the dragged corner only. - let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); - assert_eq!((r.x, r.y), (5.0, 5.0)); - assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); - // Never below the minimum size. - let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); - assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); - assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); - assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); - assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); - } - - #[test] - fn fit_cells_follows_the_margins() { - let mut l = package(); - let id = l.add_cell(7.0, 10.0).unwrap(); - l.page.margins = Margins::uniform(1.0); - assert!(l.validate().is_err(), "the cell no longer fits"); - l.fit_cells().unwrap(); - l.validate().unwrap(); - let r = l.cell(id).unwrap().rect; - assert!(l.printable().unwrap().contains(&r)); - assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); - l.page.paper = "poster".into(); - assert!(l.fit_cells().is_err()); - } - - #[test] - fn cell_aspect_survives_auto_layout() { - let mut l = package(); - let id = l.add_cell(2.5, 3.5).unwrap(); - l.auto_layout().unwrap(); - let r = l.cell(id).unwrap().rect; - assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); - } - - #[test] - fn guides_validate() { - assert!(Guides::default().validate().is_ok()); - for bad in [f64::NAN, 0.0, -1.0, 1e9] { - assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); - } - } - - #[test] - fn rect_helpers() { - let a = Rect::new(0.0, 0.0, 2.0, 2.0); - assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); - assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); - assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); - assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); - assert_eq!(a.area(), 4.0); - } -} +mod tests; diff --git a/crates/engine/src/print/layout/tests.rs b/crates/engine/src/print/layout/tests.rs new file mode 100644 index 000000000..59f3c5845 --- /dev/null +++ b/crates/engine/src/print/layout/tests.rs @@ -0,0 +1,490 @@ +use super::*; + +fn package() -> Layout { + Layout { style: Style::PicturePackage, ..Default::default() } +} + +fn assert_clean(l: &Layout) { + let area = l.printable().unwrap(); + for c in &l.cells { + assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); + assert!(c.page < l.pages); + } + assert!(!l.has_overlaps(), "{:#?}", l.cells); +} + +#[test] +fn papers_and_orientation() { + let mut p = PageSetup::default(); + assert_eq!(p.size().unwrap(), (8.5, 11.0)); + p.orientation = Orientation::Landscape; + assert_eq!(p.size().unwrap(), (11.0, 8.5)); + assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); + p.paper = "A4".into(); + let (w, h) = p.size().unwrap(); + assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); + for n in paper_names() { + p.paper = n.into(); + assert!(p.printable().is_ok(), "{n}"); + } + p.paper = "custom".into(); + p.custom_width = 20.0; + p.custom_height = 30.0; + assert_eq!(p.size().unwrap(), (30.0, 20.0)); + p.paper = "poster".into(); + assert!(p.size().is_err()); +} + +/// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as +/// aliases, and the ISO/US papers around them unchanged. +#[test] +fn japanese_photo_and_postcard_papers() { + let pt = |name: &str| { + let p = PageSetup { paper: name.into(), ..Default::default() }; + p.size_points().unwrap_or_else(|e| panic!("{name}: {e}")) + }; + for (name, w_mm, h_mm) in [ + ("L判", 89.0, 127.0), + ("2L判", 127.0, 178.0), + ("KG判", 102.0, 152.0), + ("はがき", 100.0, 148.0), + ("往復はがき", 148.0, 200.0), + ("六切", 203.0, 254.0), + ("四切", 254.0, 305.0), + ("名刺", 55.0, 91.0), + ] { + let (w, h) = pt(name); + let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0); + assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}"); + assert!((w - h).abs() > 1e-6, "{name} is portrait"); + } + // The customary and English spellings resolve to the same paper, including spaces and case. + for (a, b) in [ + ("L判", "l"), + ("L判", " L "), + ("L判", "L-size"), + ("2L判", "2l"), + ("KG判", "kg"), + ("はがき", "postcard"), + ("はがき", "ハガキ"), + ("往復はがき", "double-postcard"), + ("六切", "6cut"), + ("六切", "6切"), + ("四切", "4cut"), + ("名刺", "business-card"), + ("A4", "a 4"), + ("4 × 6", "4x6"), + ] { + assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}"); + } + // Punctuation is ignored, including the periods and Japanese marks a pasted name may carry. + for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")] + { + assert_eq!(paper_name(name), Some(canonical), "{name}"); + } + assert_eq!(paper_name("custom"), None); + assert!(paper_size("poster").is_none()); + // The papers that were already there keep their sizes. + assert_eq!(pt("letter"), (612.0, 792.0)); + assert_eq!(pt("4x6"), (288.0, 432.0)); + assert_eq!(pt("5x7"), (360.0, 504.0)); + assert_eq!(pt("8x10"), (576.0, 720.0)); + assert_eq!(pt("13x19"), (936.0, 1368.0)); + let (w, h) = pt("A4"); + assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}"); +} + +#[test] +fn hostile_numbers_are_rejected() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { + let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; + assert!(p.size().is_err(), "{bad}"); + let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; + assert!(p.printable().is_err(), "{bad}"); + let mut l = package(); + assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); + assert!(l.add_cell(2.0, bad).is_err()); + let id = l.add_cell(2.0, 2.0).unwrap(); + assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); + assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + let mut g = Layout { spacing: bad, ..package() }; + assert!(g.validate().is_err()); + assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); + assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); + } + // Margins that leave nothing. + let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; + assert!(p.printable().is_err()); + let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; + assert!(l.validate().is_err()); + l.grid.columns = 21; + assert!(l.validate().is_err()); +} + +#[test] +fn units_convert() { + for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { + assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); + } + assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); + assert_eq!(Unit::Points.from_inches(2.0), 144.0); + assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); +} + +#[test] +fn contact_grid_fills_the_printable_area() { + let l = Layout { + style: Style::ContactSheet, + grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, + ..Default::default() + }; + let area = l.printable().unwrap(); + let cells = l.grid_rects().unwrap(); + assert_eq!(cells.len(), 12); + let first = cells.first().unwrap(); + let last = cells.last().unwrap(); + assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); + assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); + for (i, a) in cells.iter().enumerate() { + assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); + for b in cells.iter().skip(i + 1) { + assert!(!a.overlaps(b)); + } + } + // Square cells are centred. + let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; + let cells = sq.grid_rects().unwrap(); + assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); + let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); + assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); + // Single image ignores the grid. + let one = Layout { style: Style::SingleImage, ..l }; + assert_eq!(one.grid_rects().unwrap(), vec![area]); +} + +#[test] +fn page_counts() { + let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; + assert_eq!(l.page_count(0), 1); + assert_eq!(l.page_count(4), 1); + assert_eq!(l.page_count(5), 2); + l.style = Style::SingleImage; + assert_eq!(l.page_count(3), 3); + l.style = Style::PicturePackage; + l.pages = 4; + assert_eq!(l.page_count(99), 4); +} + +#[test] +fn packer_is_deterministic_and_clean() { + let area = Layout::default().printable().unwrap(); + let sizes: Vec<(f64, f64)> = + [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] + .to_vec(); + let a = pack(&sizes, area, 0.1, true).unwrap(); + assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); + assert_eq!(a.len(), sizes.len()); + for (i, (p, r, turned)) in a.iter().enumerate() { + assert!(area.contains(r), "{r:?}"); + let (w, h) = sizes[i]; + let expect = if *turned { (h, w) } else { (w, h) }; + assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); + for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { + if p == q { + // The spacing is kept between neighbours. + let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); + assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); + } + } + } + // The input order does not hide a block that needs a page of its own. + assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); +} + +#[test] +fn packer_rotates_only_when_needed_and_refuses_oversize() { + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + // 10 x 5 only fits a portrait page turned. + let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); + assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); + assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); + let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); + assert!(!r[0].2, "a block that fits as given is not turned"); + assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); + // A block that exactly fills the area fits despite the spacing. + assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); + assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); + // More pages than allowed. + assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); + assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); +} + +#[test] +fn packing_is_dense() { + // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); + assert!(r.iter().all(|p| p.0 == 0)); + // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). + let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); + assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); +} + +#[test] +fn add_cell_fills_gaps_then_opens_pages() { + let mut l = package(); + let a = l.add_cell(3.9, 6.0).unwrap(); + let b = l.add_cell(3.9, 6.0).unwrap(); + assert_eq!(l.cell(a).unwrap().rect.x, 0.25); + assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); + assert_clean(&l); + // Keep adding until a second page appears. + for _ in 0..6 { + l.add_cell(3.9, 6.0).unwrap(); + } + assert!(l.pages >= 2); + assert_clean(&l); + assert_eq!(l.cells.len(), 8); + assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); + let mut grid = Layout::default(); + assert!(grid.add_cell(1.0, 1.0).is_err()); +} + +#[test] +fn cell_cap() { + let mut l = package(); + for _ in 0..MAX_CELLS { + if l.add_cell(0.5, 0.5).is_err() { + break; + } + } + assert!(l.cells.len() <= MAX_CELLS); + let n = l.cells.len(); + if n == MAX_CELLS { + assert!(l.add_cell(0.5, 0.5).is_err()); + } +} + +#[test] +fn move_and_resize_stay_inside() { + let mut l = package(); + let id = l.add_cell(2.0, 3.0).unwrap(); + let r = l.move_cell(id, 100.0, -5.0).unwrap(); + let area = l.printable().unwrap(); + assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); + let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); + assert!(area.contains(&r)); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); + assert!(l.move_cell(77, 0.0, 0.0).is_err()); + l.delete_cell(id).unwrap(); + assert!(l.delete_cell(id).is_err()); +} + +#[test] +fn stacking_order() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let ids: Vec = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); + let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::>(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::ToFront).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::ToBack).unwrap(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), ids); + assert!(l.restack(99, Stack::ToBack).is_err()); +} + +#[test] +fn stacking_ignores_other_pages() { + let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; + let a = l.add_cell(1.0, 1.0).unwrap(); + let b = l.add_cell(1.0, 1.0).unwrap(); + let c = l.add_cell(1.0, 1.0).unwrap(); + l.set_cell_page(b, 1).unwrap(); + l.restack(a, Stack::ToFront).unwrap(); + // `a` goes past `c` (same page) and not necessarily past `b`. + let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); + assert!(pos(a) > pos(c)); +} + +#[test] +fn overlap_is_allowed_by_move_and_reported() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(3.0, 3.0).unwrap(); + assert!(!l.has_overlaps()); + let r = l.cell(a).unwrap().rect; + l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); + assert!(l.has_overlaps()); + // Touching edges are not an overlap. + l.move_cell(b, r.right(), r.y).unwrap(); + assert!(!l.has_overlaps()); +} + +#[test] +fn auto_layout_repacks_cleanly_and_keeps_photos() { + let mut l = package(); + for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { + l.add_cell(w, h).unwrap(); + } + let first = l.cells[0].id; + l.set_cell_photo(first, Some(42)).unwrap(); + // Scramble positions to overlap, then auto layout. + for c in &mut l.cells { + c.rect.x = 0.25; + c.rect.y = 0.25; + c.page = 0; + } + l.pages = 1; + assert!(l.has_overlaps()); + l.auto_layout().unwrap(); + assert_clean(&l); + assert_eq!(l.cell(first).unwrap().photo, Some(42)); + assert_eq!(l.cells.len(), 6); + let again = l.clone(); + let mut l2 = again.clone(); + l2.auto_layout().unwrap(); + l.auto_layout().unwrap(); + assert_eq!(l, l2); +} + +#[test] +fn auto_layout_failure_changes_nothing() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); + let before = l.clone(); + assert!(l.auto_layout().is_err()); + assert_eq!(l, before); +} + +#[test] +fn clear_and_new_page() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + assert_eq!(l.new_page().unwrap(), 1); + assert_eq!(l.pages, 2); + l.clear(); + assert_eq!((l.cells.len(), l.pages), (0, 1)); + for _ in 1..MAX_PAGES { + l.new_page().unwrap(); + } + assert!(l.new_page().is_err()); + let mut g = Layout::default(); + assert!(g.new_page().is_err()); +} + +#[test] +fn validate_catches_bad_cells() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + let ok = l.clone(); + let mut bad = ok.clone(); + bad.cells[0].rect.w = f64::NAN; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].page = 3; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].rect.x = 50.0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells.push(bad.cells[0].clone()); + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.pages = 0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.next_id = 0; + bad.validate().unwrap(); + assert!(bad.next_id > bad.cells[0].id); + assert!(serde_json::from_str::(r#"{"surprise":1}"#).is_err()); + let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); + round.validate().unwrap(); + assert_eq!(round, ok); +} + +#[test] +fn snapping_to_cells_grid_and_margins() { + let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(2.0, 2.0).unwrap(); + let ra = l.cell(a).unwrap().rect; + // Left edge of b near the right edge of a. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - ra.right()).abs() < 1e-9); + assert_eq!(m.y, 5.0); + // Too far: untouched. + let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); + // Right edge snaps to the printable margin. + let area = l.printable().unwrap(); + let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.right() - area.right()).abs() < 1e-9); + // Off leaves everything. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); + // Grid snapping lands on multiples from the printable corner. + let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); + // Resize snaps the dragged corner only. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); + assert_eq!((r.x, r.y), (5.0, 5.0)); + assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); + // Never below the minimum size. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); + assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); +} + +#[test] +fn fit_cells_follows_the_margins() { + let mut l = package(); + let id = l.add_cell(7.0, 10.0).unwrap(); + l.page.margins = Margins::uniform(1.0); + assert!(l.validate().is_err(), "the cell no longer fits"); + l.fit_cells().unwrap(); + l.validate().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!(l.printable().unwrap().contains(&r)); + assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); + l.page.paper = "poster".into(); + assert!(l.fit_cells().is_err()); +} + +#[test] +fn cell_aspect_survives_auto_layout() { + let mut l = package(); + let id = l.add_cell(2.5, 3.5).unwrap(); + l.auto_layout().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); +} + +#[test] +fn guides_validate() { + assert!(Guides::default().validate().is_ok()); + for bad in [f64::NAN, 0.0, -1.0, 1e9] { + assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); + } +} + +#[test] +fn rect_helpers() { + let a = Rect::new(0.0, 0.0, 2.0, 2.0); + assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); + assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); + assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); + assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); + assert_eq!(a.area(), 4.0); +} diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs index 2465c123a..7bb533612 100644 --- a/crates/engine/src/print/template.rs +++ b/crates/engine/src/print/template.rs @@ -30,6 +30,14 @@ impl PrintSettings { pub const MAX_NAME: usize = 80; /// Most templates kept. pub const MAX_TEMPLATES: usize = 200; +/// File extension of an exported print template (JSON). +pub const FILE_EXT: &str = "lcprint"; +/// `format` tag of an exported print template file. +const FILE_FORMAT: &str = "lightcraft.printTemplate"; +/// Version of the template file this build writes and the newest it reads. +const FILE_VERSION: u64 = 1; +/// Largest template file read, bytes. +pub const MAX_FILE: u64 = 256 * 1024; /// A saved print template: the layout, image settings, page and job of a print, without the /// photos assigned to cells and without the guides (a view preference). The settings are kept as @@ -74,6 +82,53 @@ impl PrintTemplate { s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?; Ok(s) } + + /// The document of an exported template: `{format, version, name, settings}`. + pub fn to_file(&self) -> serde_json::Value { + serde_json::json!({"format": FILE_FORMAT, "version": FILE_VERSION, "name": self.name, "settings": self.settings}) + } + + /// A template read from an exported file. Refused: more than [`MAX_FILE`] bytes, not JSON, + /// another format or a newer version, a bad name, and settings that fail the checks a saved + /// template's settings get. Photos assigned to cells and guides in the file are dropped. + pub fn from_file(bytes: &[u8]) -> Result { + if bytes.len() as u64 > MAX_FILE { + return Err(format!("{} bytes is too large for a print template (at most {} KB)", bytes.len(), MAX_FILE / 1024)); + } + let v: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| format!("not a print template file: {e}"))?; + if v.get("format").and_then(serde_json::Value::as_str) != Some(FILE_FORMAT) { + return Err(format!("not a print template file (`format` is not \"{FILE_FORMAT}\")")); + } + match v.get("version").and_then(serde_json::Value::as_u64) { + Some(1..=FILE_VERSION) => {} + Some(n) => return Err(format!("print template file version {n} is not one this version of LightCraft reads (1 to {FILE_VERSION})")), + None => return Err("a print template file needs a `version` number".into()), + } + let name = clean_name(v.get("name").and_then(serde_json::Value::as_str).ok_or("a print template file needs a `name`")?)?; + let settings = v.get("settings").cloned().ok_or("a print template file needs `settings`")?; + let checked = PrintTemplate { name: name.clone(), settings }.settings(&Guides::default())?; + Self::new(&name, &checked) + } +} + +/// `name` if no template in `taken` has it (in any case), else the first free `name (2)`, +/// `name (3)`…, with `name` shortened to keep the result within [`MAX_NAME`] characters. +pub fn unique_name(name: &str, taken: &[PrintTemplate]) -> String { + let free = |n: &str| !taken.iter().any(|t| t.name.eq_ignore_ascii_case(n)); + if free(name) { + return name.to_string(); + } + let mut n = 2usize; + loop { + let suffix = format!(" ({n})"); + let keep = MAX_NAME.saturating_sub(suffix.chars().count()); + let base: String = name.chars().take(keep).collect(); + let candidate = format!("{}{suffix}", base.trim_end()); + if free(&candidate) || n > taken.len() { + return candidate; + } + n += 1; + } } #[cfg(test)] @@ -121,4 +176,64 @@ mod tests { s.job.ppi = f32::NAN; assert!(PrintTemplate::new("t", &s).is_err()); } + + #[test] + fn a_template_file_reads_back_as_the_template() { + let mut s = PrintSettings::default(); + s.layout.style = Style::ContactSheet; + s.page.background = [1, 2, 3]; + let t = PrintTemplate::new("Proofs", &s).unwrap(); + let bytes = serde_json::to_vec(&t.to_file()).unwrap(); + assert_eq!(PrintTemplate::from_file(&bytes).unwrap(), t); + // a hand-written file: photos and guides are not taken in + let doc = json!({"format": FILE_FORMAT, "version": 1, "name": " Hand ", "settings": { + "layout": {"style": "customPackage", "cells": [{"id": 1, "rect": {"x": 1, "y": 1, "w": 2, "h": 2}, "photo": 9}]}, + "guides": {"gridSize": 1.0}}}); + let t = PrintTemplate::from_file(&serde_json::to_vec(&doc).unwrap()).unwrap(); + assert_eq!(t.name, "Hand"); + assert!(t.settings.get("guides").is_none()); + assert!(t.settings.pointer("/layout/cells/0/rect").is_some()); + assert_eq!(t.settings.pointer("/layout/cells/0/photo"), None); + } + + #[test] + fn hostile_template_files_are_refused() { + let file = |v: serde_json::Value| PrintTemplate::from_file(&serde_json::to_vec(&v).unwrap()).unwrap_err(); + let ok = |extra: serde_json::Value| { + let mut v = json!({"format": FILE_FORMAT, "version": 1, "name": "t", "settings": {}}); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + v + }; + assert!(PrintTemplate::from_file(b"not json").unwrap_err().contains("not a print template file")); + assert!(PrintTemplate::from_file(&vec![b' '; MAX_FILE as usize + 1]).unwrap_err().contains("too large")); + assert!(file(json!([1, 2])).contains("format")); + assert!(file(ok(json!({"format": "lightcraft.slideshowTemplate"}))).contains("format")); + assert!(file(ok(json!({"version": 2}))).contains("version 2")); + assert!(file(ok(json!({"version": 0}))).contains("version 0")); + assert!(file(ok(json!({"version": "1"}))).contains("`version` number")); + assert!(file(json!({"format": FILE_FORMAT, "name": "t", "settings": {}})).contains("`version` number")); + assert!(file(ok(json!({"name": "a\nb"}))).contains("template name")); + assert!(file(ok(json!({"name": 5}))).contains("`name`")); + assert!(file(json!({"format": FILE_FORMAT, "version": 1, "name": "t"})).contains("`settings`")); + assert!(file(ok(json!({"settings": {"job": {"ppi": 1e30}}}))).contains("template `t`")); + assert!(file(ok(json!({"settings": {"surprise": 1}}))).contains("template `t`")); + assert!(PrintTemplate::from_file(&serde_json::to_vec(&ok(json!({}))).unwrap()).is_ok()); + } + + #[test] + fn an_imported_name_never_takes_an_existing_one() { + let t = |n: &str| PrintTemplate::new(n, &PrintSettings::default()).unwrap(); + assert_eq!(unique_name("Proofs", &[]), "Proofs"); + assert_eq!(unique_name("Proofs", &[t("proofs")]), "Proofs (2)"); + assert_eq!(unique_name("Proofs", &[t("Proofs"), t("PROOFS (2)")]), "Proofs (3)"); + let long = "x".repeat(MAX_NAME); + let named = unique_name(&long, &[t(&long)]); + assert_eq!(named.chars().count(), MAX_NAME); + assert!(named.ends_with(" (2)")); + let many: Vec = std::iter::once(t("A")).chain((2..=MAX_TEMPLATES).map(|n| t(&format!("A ({n})")))).collect(); + let named = unique_name("A", &many); + assert!(!many.iter().any(|x| x.name.eq_ignore_ascii_case(&named)), "{named}"); + } } diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index c569a2e1f..8b165245b 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -32,6 +32,32 @@ use serde::Serialize; use crate::sidecar::SidecarNaming; use crate::{EngineError, Result, Session}; +mod fs; +mod moves; +mod plan; +mod sidecar; +mod tokens; + +pub(crate) use fs::{MoveFs, RealFs}; +#[cfg(test)] +use fs::{folded_path, same_contents}; +pub(crate) use moves::move_all; +#[cfg(test)] +pub(crate) use moves::move_file_with; +pub use moves::{MoveError, move_file}; +pub use plan::{plan_rename_photos, rename_photos}; +#[cfg(test)] +pub(crate) use sidecar::PacketVersion; +#[cfg(test)] +use sidecar::RetainedPacket; +pub(crate) use sidecar::{SidecarMove, retry_sidecars, validate_sidecars}; +#[cfg(test)] +use tokens::format_date; +pub use tokens::{ + DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, TokenHelp, expand, expand_folder, expand_tokens, expand_tokens_with, folder_template_error, + sample_photo, token_example, token_help_json, token_summary, unknown_tokens, +}; + /// One planned rename. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct RenamePlan { @@ -45,1733 +71,5 @@ pub struct RenamePlan { pub to_path: Option, } -fn split_ext(name: &str) -> (&str, &str) { - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => (stem, ext), - _ => (name, ""), - } -} - -fn sanitize(s: &str) -> String { - let t: String = - s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect(); - t.trim().trim_matches('.').trim().to_string() -} - -/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name -/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month -/// that doesn't parse gives nothing. -fn format_date(iso: &str, fmt: &str) -> String { - let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); - let month = iso.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); - let mut out = String::new(); - let mut it = fmt.chars(); - while let Some(c) = it.next() { - if c != '%' { - out.push(c); - continue; - } - match it.next() { - Some('Y') => out.push_str(&part(0, 4)), - Some('y') => out.push_str(&part(2, 4)), - Some('m') => out.push_str(&part(5, 7)), - Some('B') => out.push_str(month), - Some('b') => out.push_str(month.get(..3).unwrap_or(month)), - Some('d') => out.push_str(&part(8, 10)), - Some('H') => out.push_str(&part(11, 13)), - Some('M') => out.push_str(&part(14, 16)), - Some('S') => out.push_str(&part(17, 19)), - Some('%') => out.push('%'), - Some(o) => { - out.push('%'); - out.push(o); - } - None => out.push('%'), - } - } - out -} - -/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and -/// command description shows; a test checks that [`expand_tokens`] knows each of them. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -pub struct TokenHelp { - /// The token as typed, e.g. `{seq:3}`. - pub tag: &'static str, - /// Other spellings with the same meaning. - pub aliases: &'static [&'static str], - pub meaning: &'static str, -} - -/// Every template token, in the order the help shows them. -pub const TOKENS: &[TokenHelp] = &[ - TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, - TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, - TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, - TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, - TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, - TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, - TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, - TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, - TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, - TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, - TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, - TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, - TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, - TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, -]; - -/// The `%` directives of `{date:…}`. -pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ - ("%Y", "year, 4 digits"), - ("%y", "year, 2 digits"), - ("%m", "month 01–12"), - ("%B", "month name (January)"), - ("%b", "short month name (Jan)"), - ("%d", "day 01–31"), - ("%H", "hour 00–23"), - ("%M", "minute"), - ("%S", "second"), - ("%%", "a literal %"), -]; - -/// How templates behave, one sentence each (shown under the token list). -pub const TEMPLATE_NOTES: &[&str] = &[ - "The original extension is always added; {ext} only puts it inside the name as well.", - "A blank template keeps the original names.", - "{date} is the capture time; a photo without one uses the time it was imported.", - "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", - "Unknown tags stay as typed — check the preview for a {typo}.", - "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", -]; - -/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. -pub fn token_summary() -> String { - TOKENS.iter().map(|t| t.tag).collect::>().join(" ") -} - -/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). -pub fn sample_photo() -> Photo { - let mut p = - Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); - p.captured = Some("2026-01-14T05:58:48".into()); - p.rating = 4; - p.meta.camera = "Canon EOS R5".into(); - p.meta.lens = "RF24-70mm F2.8".into(); - p.meta.iso = Some(400); - p.meta.title = "Harbour".into(); - p.meta.creator = "Ann Lee".into(); - p -} - -/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. -pub fn token_example(tag: &str) -> String { - expand_tokens(tag, &sample_photo(), 1, 1) -} - -/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be -/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels. -pub fn folder_template_error(template: &str) -> Option { - let t = template.trim(); - if t.is_empty() { - return Some("the folder template is empty".into()); - } - let b = t.as_bytes(); - if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') { - return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into()); - } - if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) { - return Some("the folder template may not contain . or .. folders".into()); - } - None -} - -/// The folders (relative to the destination) a folder template gives `p`: the template's own `/` -/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder -/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and -/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty -/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s -/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped. -pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec { - template - .split(['/', '\\']) - .map(str::trim) - .filter(|s| !s.is_empty() && !matches!(*s, "." | "..")) - .map(|s| { - let v = sanitize(&expand_tokens(s, p, seq, 1)); - if v.is_empty() { "unknown".to_string() } else { v } - }) - .collect() -} - -/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. -pub fn unknown_tokens(template: &str) -> Vec { - let p = sample_photo(); - let mut out = Vec::new(); - let mut rest = template; - while let Some(i) = rest.find('{') { - let Some(j) = rest[i..].find('}') else { break }; - let tag = &rest[i..i + j + 1]; - if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { - out.push(tag.to_string()); - } - rest = &rest[i + j + 1..]; - } - out -} - -/// Every token with its meaning and example, the date directives and the notes, as JSON (the -/// `photo.renameTokens` command). -pub fn token_help_json() -> serde_json::Value { - let tokens: Vec = TOKENS - .iter() - .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) - .collect(); - let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); - serde_json::json!({ - "tokens": tokens, - "dateDirectives": directives, - "notes": TEMPLATE_NOTES, - "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", - }) -} - -/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. -pub fn expand(template: &str, p: &Photo, seq: usize) -> String { - let (stem, ext) = split_ext(&p.file_name); - let out = expand_tokens(template, p, seq, 1); - let mut stem_out = sanitize(&out); - if stem_out.is_empty() { - stem_out = sanitize(stem); - } - if stem_out.is_empty() { - stem_out = "photo".into(); - } - if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") } -} - -/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` -/// is zero-padded to `seq_width` digits. -pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { - expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) -} - -/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text -/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename -/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. -pub fn expand_tokens_with( - template: &str, - p: &Photo, - seq: usize, - seq_width: usize, - extra: &dyn Fn(&str, Option<&str>, &str) -> Option, -) -> String { - let (stem, ext) = split_ext(&p.file_name); - let mut out = String::new(); - let mut rest = template; - while let Some(i) = rest.find('{') { - out.push_str(&rest[..i]); - let Some(j) = rest[i..].find('}') else { - out.push_str(&rest[i..]); - rest = ""; - break; - }; - let tok = &rest[i + 1..i + j]; - let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); - let lower = name.trim().to_ascii_lowercase(); - let v = match extra(&lower, arg, tok) { - Some(v) => v, - None => match lower.as_str() { - "name" | "filename" => stem.to_string(), - "num" => { - let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); - stem[stem.len() - digits..].to_string() - } - "seq" | "n" => { - let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); - format!("{seq:0w$}") - } - "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), - "folder" => match &p.source { - Source::File { path } => { - Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() - } - Source::Demo { .. } => String::new(), - }, - "camera" => p.meta.camera.clone(), - "lens" => p.meta.lens.clone(), - "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), - "rating" => p.rating.to_string(), - "title" => p.meta.title.clone(), - "creator" => p.meta.creator.clone(), - "ext" => ext.to_string(), - _ => format!("{{{tok}}}"), - }, - }; - out.push_str(&v); - rest = &rest[i + j + 1..]; - } - out.push_str(rest); - out -} - -/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). -fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { - let mut v = Vec::new(); - for naming in [SidecarNaming::Stem, SidecarNaming::Full] { - let p = crate::sidecar::sidecar_path(path, naming); - v.push((p.with_extension("XMP"), naming, true)); - v.push((p, naming, false)); - } - v -} - -/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of -/// a case-insensitive or case-sensitive volume (and inject failures). -pub(crate) trait MoveFs { - /// Something (file, folder, link) answers to `p`. - fn exists(&self, p: &Path) -> bool; - fn is_file(&self, p: &Path) -> bool; - /// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive - /// volume). Decided by file identity, never by comparing names: on a case-sensitive volume - /// they are two different photos. When in doubt: `false`. - fn same_file(&self, a: &Path, b: &Path) -> bool; - /// Rename `a` to `b`, failing (never replacing) when `b` exists. - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; - /// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists. - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; - fn remove_file(&self, p: &Path) -> std::io::Result<()>; - /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. - /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. - fn stem_sibling(&self, sidecar: &Path) -> Option; - /// The folder lists an entry spelled exactly like `p` (letter case included). On a - /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two - /// apart. When the folder can't be listed: `true`, so nothing is skipped. - fn listed(&self, p: &Path) -> bool { - let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; - let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; - match std::fs::read_dir(dir) { - Ok(entries) => entries.flatten().any(|e| e.file_name() == name), - Err(_) => true, - } - } - /// Content observed in a packet, independent of its pathname or inode. Model volumes - /// override this; disk implementations stream a bounded buffer without retaining the XML. - fn packet_version(&self, path: &Path) -> std::io::Result { - use std::io::Read; - let mut file = std::fs::File::open(path)?; - let mut hash = lightcraft_preview::Hasher128::new(); - let mut len = 0u64; - let mut buffer = [0u8; 16 * 1024]; - loop { - let n = match file.read(&mut buffer) { - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - result => result?, - }; - if n == 0 { - return Ok(PacketVersion { len, hash: hash.finish() }); - } - let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?; - len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?; - hash.update(bytes); - } - } -} - -/// The real file system. -pub(crate) struct RealFs; - -impl MoveFs for RealFs { - fn exists(&self, p: &Path) -> bool { - std::fs::symlink_metadata(p).is_ok() - } - - fn is_file(&self, p: &Path) -> bool { - p.is_file() - } - - fn same_file(&self, a: &Path, b: &Path) -> bool { - if a == b { - return true; - } - let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false }; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - ma.dev() == mb.dev() && ma.ino() == mb.ino() - } - #[cfg(not(unix))] - { - // no stable file id in std here: `a` and `b` are one file under two spellings when - // they look alike and the folder doesn't list both (on a case-sensitive folder - // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the - // listed one: the answer must not depend on the argument order. - let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; - let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); - if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { - return false; - } - let Ok(entries) = std::fs::read_dir(dir) else { return false }; - let (mut listed_a, mut listed_b) = (false, false); - for e in entries.flatten() { - let name = e.file_name(); - listed_a |= name == name_a; - listed_b |= name == name_b; - } - !(listed_a && listed_b) - } - } - - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); - } - // A hard link fails when `b` exists — even if it appeared since the check above — so - // nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back - // to a plain rename after the check. - match std::fs::hard_link(a, b) { - Ok(()) => std::fs::remove_file(a).inspect_err(|_| { - // both names are the same file: dropping the new one loses nothing - let _ = std::fs::remove_file(b); - }), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), - Err(_) => std::fs::rename(a, b) - .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), - } - } - - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); - } - // Streamed (a large raw or video is never read into memory whole), into a hidden name - // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so - // the partial file can't be removed either, leaves no truncated file under the photo's - // name for a retry to take for the photo. - let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't - // block the next - let partial = (0..1000u32) - .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) - .find(|p| !self.exists(p)) - .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; - let copied = (|| { - let mut from = std::fs::File::open(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; - std::io::copy(&mut from, &mut f)?; - f.sync_all() - })(); - let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); - if placed.is_err() { - let _ = std::fs::remove_file(&partial); - } - placed - } - - fn remove_file(&self, p: &Path) -> std::io::Result<()> { - std::fs::remove_file(p) - } - - fn stem_sibling(&self, sidecar: &Path) -> Option { - crate::import_move::sibling_photo(sidecar) - } -} - -/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original -/// byte for byte, and only then remove the original. A copy that doesn't match is removed. -fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { - fs.copy_no_replace(a, b)?; - if !same_contents(a, b).unwrap_or(false) { - let _ = std::fs::remove_file(b); - return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); - } - std::fs::remove_file(a).inspect_err(|_| { - // the original stays: drop the copy so the file isn't in two places - let _ = std::fs::remove_file(b); - }) -} - -/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so -/// a large raw or video is never read into memory whole. -fn same_contents(a: &Path, b: &Path) -> std::io::Result { - const CHUNK: usize = 1 << 20; - let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); - if fa.metadata()?.len() != fb.metadata()?.len() { - return Ok(false); - } - let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); - loop { - let n = read_full(&mut fa, &mut ba)?; - let m = read_full(&mut fb, &mut bb)?; - if n != m || ba.get(..n) != bb.get(..m) { - return Ok(false); - } - if n == 0 { - return Ok(true); - } - } -} - -/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer -/// only at the end of the file). -fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { - let mut filled = 0; - while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { - match r.read(rest) { - Ok(0) => break, - Ok(k) => filled = filled.saturating_add(k), - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } - Ok(filled) -} - -/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive -/// volume): the rename must go through a temporary name. -fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { - from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to) -} - -/// Windows accepts both separators; spelling differences must not hide file identity. -fn folded_path(path: &str) -> String { - if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() } -} - -/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a -/// unique temporary name so case-insensitive volumes see a real change. -fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> { - if !is_respelling(fs, a, b) { - return fs.rename_no_replace(a, b); - } - let tmp = (0..1000u32) - .map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id()))) - .find(|t| !fs.exists(t)) - .ok_or_else(|| std::io::Error::other("no free temporary name"))?; - fs.rename_no_replace(a, &tmp)?; - fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) { - Ok(()) => e, - Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())), - }) -} - -/// 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. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct SidecarMove { - pub from: PathBuf, - pub to: PathBuf, - copied: bool, - /// Observed packets in the partial move, including the separated source. Absent planned - /// destinations are never owned; a completed repair advances these observations. - companions: Vec, -} - -/// A content version, not an assertion that a pathname still names the same packet. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct PacketVersion { - len: u64, - hash: lightcraft_preview::Hash128, -} - -impl PacketVersion { - fn of(bytes: &[u8]) -> Self { - Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) } - } -} - -#[derive(Clone, Debug, PartialEq)] -struct RetainedPacket { - path: PathBuf, - // An unreadable observation remains untrusted even if a later read succeeds. - version: Option, -} - -impl RetainedPacket { - fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { - if self.version.is_none() { - return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display())); - } - let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?; - if self.version != Some(current) { - return Err(format!( - "retained XMP {} changed since the partial move; packet ownership must be resolved before retrying", - self.path.display() - )); - } - Ok(()) - } -} - -/// Advance only packets whose operation actually succeeded. A copy retains its original; -/// a move retires vanished source aliases and adds the observed destination. -fn advance_packets(packets: &mut Vec, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) { - let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return }; - if !copied { - packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path)); - } - packet.path = to.to_path_buf(); - if !packets.iter().any(|p| p.path == packet.path) { - packets.push(packet); - } -} - -fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec { - packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect() -} - -impl SidecarMove { - fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { - for packet in &self.companions { - packet.validate(fs)?; - } - Ok(()) - } - - /// Validate a queued retry and return the copy decision for this physical operation. - fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result { - self.validate_packets(fs)?; - let respelling = is_respelling(fs, &self.from, &self.to); - // Batch rollback may have restored another photo using this source packet. - let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some()); - if fs.exists(&self.to) && (copied || !respelling) { - return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display())); - } - Ok(copied) - } - - fn needed(&self, fs: &dyn MoveFs) -> bool { - fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to))) - } - - fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool { - let same_path = |endpoint: &Path| { - endpoint == path - || fs.same_file(endpoint, path) - || (endpoint.file_name() == path.file_name() - && endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b))) - }; - same_path(&self.from) || same_path(&self.to) - } - - fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> { - if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) } - } -} - -/// Complete known unfinished sidecars before moving any more photos. Completed operations -/// leave the queue; a failure retains that operation and the rest, without replacing a file. -pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec) -> std::result::Result<(), String> { - validate_sidecars(fs, pending)?; - while !pending.is_empty() { - apply_repair(fs, pending, 0)?; - } - Ok(()) -} - -/// Preflight a detached history entry before global queues are allowed to retire. The -/// caller can validate without mutating it; retry revalidates before each physical operation. -pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> { - for repair in pending { - repair.validate_repair(fs)?; - } - Ok(()) -} - -/// Finish one operation and transfer its observed content to the remaining repairs. Do not -/// infer ownership by reading an occupied destination or by retaining a planned pathname. -fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec, i: usize) -> std::result::Result<(), String> { - let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) }; - repair.copied = repair.validate_repair(fs)?; - let aliases = packet_aliases(&repair.companions, fs, &repair.from); - repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?; - for remaining in pending.iter_mut() { - advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs); - } - pending.remove(i); - Ok(()) -} - -/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals -/// must not block healthy metadata saves. Completed repairs retire; failures stay queued. -pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec, paths: &[PathBuf]) -> std::result::Result<(), String> { - for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) { - repair.validate_repair(fs)?; - } - let mut i = 0; - while let Some(m) = pending.get(i) { - if paths.iter().any(|path| m.touches(fs, path)) { - // Path/alias equality only locates an observation; validation above proves its - // content. A newly occupied, previously absent peer destination is a collision. - if let Some(path) = paths.iter().find(|path| { - fs.exists(path) - && !fs.same_file(path, &m.from) - && !fs.same_file(path, &m.to) - && !m.companions.iter().any(|known| fs.same_file(path, &known.path)) - }) { - return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display())); - } - apply_repair(fs, pending, i)?; - } else { - i += 1; - } - } - Ok(()) -} - -/// Every record came from an actual packet observed before this move. Successful physical -/// operations have already advanced its path; missing planned destinations are not recorded. -fn note_companions(pending: &mut [SidecarMove], packets: Vec) { - for repair in pending { - repair.companions.clone_from(&packets); - } -} - -/// Why [`move_file`] failed, and what it left behind. -#[derive(Clone, Debug, PartialEq)] -pub struct MoveError { - /// What went wrong, including every step that could not be undone and where that file is now. - pub message: String, - /// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file - /// back failed too. Its sidecars that did move stay with it. - pub moved: bool, - pub(crate) pending_sidecars: Vec, -} - -impl std::fmt::Display for MoveError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the -/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]). -pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> { - move_file_with(&RealFs, from, to) -} - -/// [`move_file`] on the file system `fs`. -pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> { - if Path::new(from) == Path::new(to) { - return Ok(()); - } - let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() }; - let (f, t) = (Path::new(from), Path::new(to)); - // `to` may be this very file spelled differently (case-insensitive volume); any other file - // there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced - if fs.exists(t) && !is_respelling(fs, f, t) { - return Err(fail(format!("{to} already exists"))); - } - // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the - // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. - let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); - let mut packets: Vec = - sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); - move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; - let mut done: Vec = Vec::new(); - let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { - let mut dst = crate::sidecar::sidecar_path(to, naming); - if upper { - dst = dst.with_extension("XMP"); - } - // A stem sidecar another photo still uses is copied, not taken away (issue #92). - let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); - SidecarMove { from: sc, to: dst, copied, companions: Vec::new() } - }); - while let Some(m) = remaining.next() { - if !m.needed(fs) { - continue; // never overwrite: the old sidecar stays where it was - } - let aliases = packet_aliases(&packets, fs, &m.from); - if let Err(e) = m.apply(fs) { - let mut message = format!("rename {}: {e}", m.from.display()); - // The file first: if it can't go back, sidecars that moved stay with it. - if let Err(e) = move_one(fs, t, f) { - message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}")); - if !done.is_empty() { - let names: Vec = done.iter().map(|m| m.to.display().to_string()).collect(); - message.push_str(&format!(" with its sidecar {}", names.join(", "))); - } - let mut pending_sidecars = vec![m]; - for m in remaining.filter(|m| m.needed(fs)) { - // The two case spellings can name one file. Keep one operation for - // those aliases, while retaining distinct sidecars on sensitive volumes. - let alias = pending_sidecars.iter().any(|p| { - folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy()) - && folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy()) - && fs.same_file(&p.from, &m.from) - }); - if !alias { - pending_sidecars.push(m); - } - } - note_companions(&mut pending_sidecars, packets); - return Err(MoveError { message, moved: true, pending_sidecars }); - } - let mut pending_sidecars = Vec::new(); - for m in done.iter().rev() { - if m.copied { - // The original is still there: a leftover copy loses nothing. - if fs.remove_file(&m.to).is_ok() { - packets.retain(|p| p.path != m.to); - } - } else { - let aliases = packet_aliases(&packets, fs, &m.to); - if let Err(e) = move_one(fs, &m.to, &m.from) { - message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display())); - pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() }); - } else { - advance_packets(&mut packets, &aliases, &m.from, false, fs); - } - } - } - note_companions(&mut pending_sidecars, packets); - return Err(MoveError { message, moved: false, pending_sidecars }); - } - advance_packets(&mut packets, &aliases, &m.to, m.copied, fs); - done.push(m); - } - Ok(()) -} - -/// What a failed batch of moves ([`move_all`]) left behind. -#[derive(Debug)] -pub(crate) struct BatchMoveError { - /// The first failure and every move that could not be undone. - pub message: String, - /// Moves that stayed done, (from, to): those files are at `to` now. - pub stuck: Vec<(String, String)>, - /// Sidecars still separated from the path where their photo ended up, in either direction. - pub pending_sidecars: Vec, -} - -/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone. -/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored. -pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> { - let mut done: Vec<&(String, String)> = Vec::new(); - for m in moves { - let Err(e) = move_file_with(fs, &m.0, &m.1) else { - done.push(m); - continue; - }; - let mut message = e.message; - let mut pending_sidecars = e.pending_sidecars; - let mut stuck = Vec::new(); - if e.moved { - stuck.push(m.clone()); - } - for d in done.iter().rev() { - if let Err(mut e) = move_file_with(fs, &d.1, &d.0) { - message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message)); - if !e.moved { - stuck.push((*d).clone()); - } - pending_sidecars.append(&mut e.pending_sidecars); - } - } - return Err(BatchMoveError { message, stuck, pending_sidecars }); - } - Ok(()) -} - -/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward. -fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec) { - let mut pending = vec![op]; - let mut order = Vec::new(); - let mut final_ops = HashMap::new(); - while let Some(op) = pending.pop() { - match op { - Op::Batch { ops } => pending.extend(ops.iter().rev()), - Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id), - _ => {} - } - } - out.extend(order.into_iter().filter_map(|id| { - let op = final_ops.get(&id).copied()?; - matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone()) - })); -} - -/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) -/// rename once. No file-system calls. -pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec> { - let mut seen_paths: HashSet = HashSet::new(); - ids.iter() - .filter_map(|id| cat.photo(*id)) - .filter(|p| match &p.source { - Source::File { path } => seen_paths.insert(path.clone()), - _ => true, - }) - .cloned() - .collect() -} - -/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check -/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow -/// drive). Whether a case-only change names this very file is decided on the disk. -pub fn plan_rename_photos(photos: &[std::sync::Arc], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec { - plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b)) -} - -/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether -/// two spellings name one file (see [`MoveFs`]). -fn plan_rename_core( - photos: &[std::sync::Arc], - template: &str, - start: usize, - exists: &dyn Fn(&Path) -> bool, - same_file: &dyn Fn(&Path, &Path) -> bool, -) -> Vec { - let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch - let mut out = Vec::new(); - for (i, p) in photos.iter().enumerate() { - let seq = start.saturating_add(i); - let want = expand(template, p, seq); - let (stem, ext) = split_ext(&want); - let (stem, ext) = (stem.to_string(), ext.to_string()); - let candidate = |k: usize| { - if k == 0 { - want.clone() - } else if ext.is_empty() { - format!("{stem}-{k}") - } else { - format!("{stem}-{k}.{ext}") - } - }; - let (to, to_path) = match &p.source { - Source::File { path } => { - let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); - let mut k = 0; - loop { - let name = candidate(k); - let tp = dir.join(&name).to_string_lossy().to_string(); - let key = folded_path(&tp); - // this very file, maybe spelled differently (case-insensitive volume)? By - // identity: on a case-sensitive volume `img_1.jpg` may be another photo - let (from, to) = (Path::new(path), Path::new(&tp)); - let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to)); - // free: not claimed in this batch and not on disk (unless it is this very file). - // A file this batch moves away still counts as taken: simple and safe. - if !taken.contains(&key) && (same || !exists(to)) { - taken.insert(key); - break (name, Some(tp)); - } - k += 1; - } - } - Source::Demo { .. } => { - let mut k = 0; - loop { - let name = candidate(k); - if taken.insert(format!("demo:{}", name.to_lowercase())) { - break (name, None); - } - k += 1; - } - } - }; - let from_path = match &p.source { - Source::File { path } => Some(path.clone()), - _ => None, - }; - out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path }); - } - out -} - -impl Session { - /// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions. - pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec { - self.plan_rename_with(&RealFs, ids, template, start) - } - - /// [`Session::plan_rename`] on the file system `fs`. - pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec { - plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b)) - } - - /// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op - /// that updates the photos (and their virtual copies). - /// - /// When a failure can't be fully rolled back, the files that kept their new names are - /// committed as a smaller undoable rename, so the library points at them (nothing goes - /// missing), and the error lists them (old → new). - pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result { - self.apply_rename_with(&RealFs, plans) - } - - /// Complete retained repairs before a file or folder change can stale their exact paths. - pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> { - for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { - repair.validate_repair(fs).map_err(EngineError::Other)?; - } - retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?; - for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { - retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?; - } - Ok(()) - } - - pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { - self.validate_sidecar_packets_for_paths(paths, fs)?; - retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?; - for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { - retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?; - } - Ok(()) - } - - /// A healthy shared packet may be saved while another photo's repair is unavailable. - /// Check its retained content too, without forcing that unrelated physical operation. - pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { - for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { - if paths.iter().any(|path| repair.touches(fs, path)) { - repair.validate_repair(fs).map_err(EngineError::Other)?; - } else { - for packet in &repair.companions { - if paths.iter().any(|path| fs.same_file(path, &packet.path)) { - packet.validate(fs).map_err(EngineError::Other)?; - } - } - } - } - Ok(()) - } - - /// Only a successful application write updates a retained content version. Atomic writes - /// replace inodes; aliases that now resolve to the written path observe the new content. - pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) { - let version = PacketVersion::of(bytes); - for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) { - for packet in &mut repair.companions { - if RealFs.same_file(path, &packet.path) { - packet.version = Some(version); - } - } - } - } - - /// [`Session::apply_rename`] on the file system `fs`. - pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { - self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) - } - - /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars - /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the - /// photos (and their virtual copies) at the new paths. Undo moves the files back. - pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { - // A partly completed rename can already have the requested photo name. Repair - // its sidecars before treating that empty move list as a successful retry. - self.repair_sidecars_with(fs)?; - let mut ops = Vec::new(); - let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); - for p in self.catalog.photos() { - let plan = match &p.source { - Source::File { path } => by_path.get(path.as_str()).copied(), - Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()), - }; - let Some(plan) = plan else { continue }; - let source = match &plan.to_path { - Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(), - Some(t) => Source::File { path: t.clone() }, - None => p.source.clone(), - }; - if plan.to != p.file_name || source != p.source { - ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source }); - } - } - let n = ops.len(); - let op = Op::Batch { ops }; - let moves: Vec<(String, String)> = plans - .iter() - .filter_map(|pl| match (&pl.from_path, &pl.to_path) { - (Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())), - _ => None, - }) - .collect(); - if let Err(e) = move_all(fs, &moves) { - return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); - } - if n > 0 - && let Err(err) = self.commit(&label(n), op) - { - let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); - if let Err(e) = move_all(fs, &back) { - return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message))); - } - return Err(err); - } - Ok(n) - } - - /// After a failed batch of moves for `op`: point the photos whose files stayed moved at - /// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged - /// change), so the library matches the disk. Returns the message and, for a history retry, - /// the correction's inverse file ops (so a retry doesn't lose the original paths). - pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec) { - let mut msg = e.message; - let pending_sidecars = e.pending_sidecars; - if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) { - return (msg, Vec::new()); - } - let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect(); - let mut keep = Vec::new(); - set_file_ops_to(op, &to, &mut keep); - let list: Vec = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect(); - let n = e.stuck.len(); - let s = if n == 1 { "" } else { "s" }; - let fix = Op::Batch { ops: keep }; - let mut inverses = Vec::new(); - let applied = if undoable { - let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") }; - self.commit(&label, fix).map(|()| { - if let Some(entry) = self.undo.last_mut() { - entry.retry_sidecars.extend(pending_sidecars); - } - }) - } else { - match self.apply_catalog(fix.clone()) { - Ok(inv) => { - inverses = match inv { - Op::Batch { ops } => ops, - op => vec![op], - }; - self.pending_log.push(fix); - Ok(()) - } - Err(e) => Err(e), - } - }; - match applied { - Ok(()) if n > 0 => msg - .push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))), - Ok(()) => {} - Err(err) => msg.push_str(&format!( - ". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}", - list.join(", ") - )), - } - (msg, inverses) - } - - /// Physical moves to the final `SetFile` destination of each photo. A merged history - /// batch can rename the same photo repeatedly; intermediate catalog paths need no file. - pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { - let mut pending = vec![op]; - let mut order = Vec::new(); - let mut targets = HashMap::new(); - while let Some(op) = pending.pop() { - match op { - Op::Batch { ops } => pending.extend(ops.iter().rev()), - Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => { - order.push(*id); - } - _ => {} - } - } - // Virtual copies share one physical move, keeping the original batch order. - let mut seen = HashSet::new(); - order - .into_iter() - .filter_map(|id| { - let Source::File { path: to } = targets.get(&id).copied()? else { return None }; - let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None }; - (Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone())) - }) - .collect() - } -} - #[cfg(test)] -pub(crate) mod tests { - use super::*; - use std::cell::RefCell; - - /// Windows has no file id in std: two spellings of one file are the same file whichever is - /// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the - /// known `Trip.xmp`), and only the spelling on disk is listed. - #[cfg(windows)] - #[test] - fn same_file_spellings_are_symmetric_on_windows() { - let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp")); - std::fs::write(&real, b"packet").unwrap(); - std::fs::write(&other, b"packet").unwrap(); - assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real)); - assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias)); - assert!(RealFs.listed(&real) && !RealFs.listed(&alias)); - std::fs::remove_dir_all(&dir).unwrap(); - } - - /// The cross-volume move's check compares files chunk by chunk: equal files across several - /// chunks, a difference in the last chunk, different lengths, a missing file. - #[test] - fn same_contents_compares_in_chunks() { - let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let big: Vec = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect(); - let (a, b) = (dir.join("a"), dir.join("b")); - std::fs::write(&a, &big).unwrap(); - std::fs::write(&b, &big).unwrap(); - assert!(same_contents(&a, &b).unwrap()); - let mut other = big.clone(); - *other.last_mut().unwrap() ^= 1; - std::fs::write(&b, &other).unwrap(); - assert!(!same_contents(&a, &b).unwrap()); - std::fs::write(&b, &big[..big.len() - 1]).unwrap(); - assert!(!same_contents(&a, &b).unwrap()); - std::fs::write(&a, b"").unwrap(); - std::fs::write(&b, b"").unwrap(); - assert!(same_contents(&a, &b).unwrap()); - assert!(same_contents(&a, &dir.join("missing")).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or - /// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail. - pub(crate) struct FakeFs { - pub ci: bool, - pub files: RefCell)>>, - pub fail: RefCell>, - } - - impl FakeFs { - pub fn new(ci: bool, files: &[(&str, &str)]) -> FakeFs { - FakeFs { - ci, - files: RefCell::new(files.iter().map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())).collect()), - fail: RefCell::new(Vec::new()), - } - } - /// The model volume spells paths with `/`; the code under test joins them with the host's separator. - fn norm(p: &Path) -> String { - p.to_string_lossy().replace('\\', "/") - } - fn idx(&self, p: &Path) -> Option { - let p = Self::norm(p); - self.files.borrow().iter().position(|(f, _)| { - let f = f.replace('\\', "/"); - if self.ci { folded_path(&f) == folded_path(&p) } else { f == p } - }) - } - /// The listing: (exact path, contents), sorted. - pub fn listing(&self) -> Vec<(String, String)> { - let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect(); - v.sort(); - v - } - } - - impl MoveFs for FakeFs { - fn packet_version(&self, path: &Path) -> std::io::Result { - let i = self.idx(path).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such packet"))?; - self.files.borrow().get(i).map(|(_, bytes)| PacketVersion::of(bytes)).ok_or_else(|| std::io::Error::other("packet disappeared")) - } - fn exists(&self, p: &Path) -> bool { - self.idx(p).is_some() - } - fn is_file(&self, p: &Path) -> bool { - self.exists(p) - } - fn same_file(&self, a: &Path, b: &Path) -> bool { - self.idx(a).is_some() && self.idx(a) == self.idx(b) - } - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { - return Err(std::io::Error::other("injected failure")); - } - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); - } - let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; - self.files.borrow_mut()[i].0 = Self::norm(b); - Ok(()) - } - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { - return Err(std::io::Error::other("injected failure")); - } - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); - } - let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; - let c = self.files.borrow()[i].1.clone(); - self.files.borrow_mut().push((Self::norm(b), c)); - Ok(()) - } - fn remove_file(&self, p: &Path) -> std::io::Result<()> { - let i = self.idx(p).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; - self.files.borrow_mut().remove(i); - Ok(()) - } - fn listed(&self, p: &Path) -> bool { - let p = Self::norm(p); - self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p) - } - fn stem_sibling(&self, sidecar: &Path) -> Option { - let stem = Self::norm(&sidecar.with_extension("")); - self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| { - let p = Path::new(f); - Self::norm(&p.with_extension("")) == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp")) - }) - } - } - - fn file_photo(id: u64, path: &str) -> Photo { - let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - // the host's separator, so `dir.join(name)` in the planner spells the same path the same way - let path = path.replace('/', std::path::MAIN_SEPARATOR_STR); - Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00") - } - - /// Compare the unfinished operation and every observed packet after partial progress. - /// Companion order is incidental; paths and recorded content versions are exact. - pub(crate) fn assert_repair_packets(actual: &SidecarMove, unfinished: &SidecarMove, packets: &[(PathBuf, &[u8])]) { - let mut expected = unfinished.clone(); - expected.companions = - packets.iter().map(|(path, bytes)| RetainedPacket { path: path.clone(), version: Some(PacketVersion::of(bytes)) }).collect(); - let mut actual = actual.clone(); - actual.companions.sort_by(|a, b| a.path.cmp(&b.path)); - expected.companions.sort_by(|a, b| a.path.cmp(&b.path)); - assert_eq!(actual, expected); - } - - #[test] - fn folder_moves_repair_retained_sidecars_before_changing_paths() { - for command in ["folder.rename", "folder.move"] { - for queue in ["pending", "undo", "redo"] { - for (naming, copied) in [(SidecarNaming::Stem, false), (SidecarNaming::Full, false), (SidecarNaming::Stem, true)] { - let root = crate::tests_xmp::temp_dir("folder-sidecar-repair"); - let from = root.join("Trip"); - std::fs::create_dir_all(&from).unwrap(); - let photo = from.join("b.jpg"); - std::fs::write(&photo, b"Photo bytes").unwrap(); - let old_sidecar = crate::sidecar::sidecar_path(&from.join("a.jpg").to_string_lossy(), naming); - let sidecar = crate::sidecar::sidecar_path(&photo.to_string_lossy(), naming); - std::fs::write(&old_sidecar, b"Retained edits").unwrap(); - // An ordinary occupied destination must block repair without replacing its bytes. - std::fs::write(&sidecar, b"Other edits").unwrap(); - let mut s = Session::new(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, &photo.to_string_lossy())) }).unwrap(); - if copied { - let sibling = from.join("a.raw"); - std::fs::write(&sibling, b"Shared original").unwrap(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(2, &sibling.to_string_lossy())) }).unwrap(); - } - s.commit("Rating", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap(); - if queue == "redo" { - s.undo_step().unwrap(); - } - let repair = SidecarMove { - from: old_sidecar.clone(), - to: sidecar.clone(), - copied, - companions: vec![RetainedPacket { path: old_sidecar.clone(), version: Some(RealFs.packet_version(&old_sidecar).unwrap()) }], - }; - match queue { - "undo" => s.undo.last_mut().unwrap().retry_sidecars.push(repair), - "redo" => s.redo.last_mut().unwrap().retry_sidecars.push(repair), - _ => s.pending_sidecar_repairs.push(repair), - } - let params = if command == "folder.rename" { - serde_json::json!({"path": from, "name": "Holiday"}) - } else { - serde_json::json!({"path": from, "into": root.join("Archive")}) - }; - let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") }; - let before = s.catalog.to_snapshot(); - let history = (s.undo.len(), s.redo.len()); - assert!(s.execute(command, ¶ms).is_err()); - assert!(from.is_dir() && !to.exists()); - assert_eq!(s.catalog.to_snapshot(), before); - assert_eq!((s.undo.len(), s.redo.len()), history); - assert_eq!(std::fs::read(&old_sidecar).unwrap(), b"Retained edits"); - assert_eq!(std::fs::read(&sidecar).unwrap(), b"Other edits"); - std::fs::remove_file(&sidecar).unwrap(); - s.execute(command, ¶ms).unwrap(); - assert!(s.pending_sidecar_repairs.is_empty()); - assert!(s.undo.iter().chain(&s.redo).all(|e| e.retry_sidecars.is_empty())); - let moved_sidecar = to.join(sidecar.file_name().unwrap()); - assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); - assert_eq!(to.join(old_sidecar.file_name().unwrap()).exists(), copied); - for (step, current) in [("edit.undo", &from), ("edit.redo", &to)] { - s.execute(step, &serde_json::json!({})).unwrap(); - assert_eq!( - s.catalog.photo(PhotoId(1)).unwrap().source, - Source::File { path: current.join("b.jpg").to_string_lossy().into_owned() } - ); - assert_eq!(std::fs::read(current.join(sidecar.file_name().unwrap())).unwrap(), b"Retained edits"); - assert_eq!(current.join(old_sidecar.file_name().unwrap()).exists(), copied); - } - // Explicit IDs choose targets after the command's selection gate. - s.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); - s.execute("photo.rename", &serde_json::json!({"ids": [1], "template": "Latest"})).unwrap(); - s.execute("edit.undo", &serde_json::json!({})).unwrap(); - assert!(to.join("b.jpg").is_file()); - assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); - s.execute("edit.redo", &serde_json::json!({})).unwrap(); - assert!(to.join("Latest.jpg").is_file()); - let final_sidecar = crate::sidecar::sidecar_path(&to.join("Latest.jpg").to_string_lossy(), naming); - assert_eq!(std::fs::read(final_sidecar).unwrap(), b"Retained edits"); - std::fs::remove_dir_all(root).unwrap(); - } - } - } - } - - #[cfg(windows)] - #[test] - fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() { - let fs = FakeFs::new(true, &[("N:/photos/IMG.jpg", "image"), ("N:/photos/IMG.xmp", "edits"), ("N:/photos/other.jpg", "other")]); - // Any attempt to move the unchanged photo (including rollback) must fail. - fs.fail.borrow_mut().push("IMG".into()); - let mut s = Session::new(); - // spelled with `/` on purpose (not `file_photo`, which uses the host's separator) - for (id, path) in [(1, "N:/photos/IMG.jpg"), (2, "N:/photos/other.jpg")] { - let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00"); - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - } - let before = fs.listing(); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1); - assert_ne!(plans[0].from_path, plans[0].to_path, "fixture exercises different separator spelling"); - assert_eq!(s.apply_rename_with(&fs, &plans).unwrap(), 0); - assert_eq!(fs.listing(), before); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "N:/photos/IMG.jpg".into() }); - assert!(s.undo.is_empty()); - move_file_with(&fs, "N:/photos/IMG.jpg", "N:/photos\\IMG.jpg").unwrap(); - let separator_only = Op::SetFile { id: PhotoId(1), file_name: "IMG.jpg".into(), source: Source::File { path: "N:/photos\\IMG.jpg".into() } }; - assert!(s.file_moves(&separator_only).is_empty()); - - // A batch may combine that no-op with a genuine rename, without touching IMG or its sidecar. - let mut batch = plans; - batch.extend(s.plan_rename_with(&fs, &[PhotoId(2)], "renamed", 1)); - assert_eq!(s.apply_rename_with(&fs, &batch).unwrap(), 1); - assert_eq!( - fs.listing(), - vec![ - ("N:/photos/IMG.jpg".into(), "image".into()), - ("N:/photos/IMG.xmp".into(), "edits".into()), - ("N:/photos/renamed.jpg".into(), "other".into()) - ] - ); - let undo = &s.undo.last().unwrap().op; - let moves = s.file_moves(undo); - assert_eq!(moves.len(), 1); - assert_eq!(Path::new(&moves[0].1), Path::new("N:/photos/other.jpg")); - move_all(&fs, &moves).unwrap(); - s.catalog.apply(undo.clone()).unwrap(); - assert_eq!(fs.listing(), before); - } - - /// Issue #95: a case-only rename is a real change on a case-insensitive volume and never - /// replaces a different file that differs only by case on a case-sensitive one. - #[test] - fn case_only_renames_never_replace_another_file() { - // case-sensitive: IMG_1.JPG and img_1.JPG are two photos - let fs = FakeFs::new(false, &[("/p/IMG_1.JPG", "first"), ("/p/img_1.JPG", "second"), ("/p/IMG_1.xmp", "sc1"), ("/p/img_1.xmp", "sc2")]); - assert!(move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap_err().message.contains("already exists")); - assert_eq!(fs.listing().len(), 4); - assert!(fs.listing().contains(&("/p/img_1.JPG".into(), "second".into()))); - // the planner picks a free name instead - let mut s = Session::new(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); - assert_eq!(plans[0].to, "img_1-1.JPG"); - move_file_with(&fs, "/p/IMG_1.JPG", plans[0].to_path.as_deref().unwrap()).unwrap(); - assert_eq!( - fs.listing(), - vec![ - ("/p/img_1-1.JPG".into(), "first".into()), - ("/p/img_1-1.xmp".into(), "sc1".into()), - ("/p/img_1.JPG".into(), "second".into()), - ("/p/img_1.xmp".into(), "sc2".into()) - ] - ); - // case-sensitive, target free: a plain rename - let fs = FakeFs::new(false, &[("/p/IMG_2.JPG", "x")]); - move_file_with(&fs, "/p/IMG_2.JPG", "/p/img_2.JPG").unwrap(); - assert_eq!(fs.listing(), vec![("/p/img_2.JPG".into(), "x".into())]); - - // case-insensitive: the "existing" target is the photo itself → renamed via a temp name - let fs = FakeFs::new(true, &[("/p/IMG_1.JPG", "first"), ("/p/IMG_1.xmp", "sc1")]); - let mut s = Session::new(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); - assert_eq!(plans[0].to, "img_1.JPG", "not a collision with itself"); - move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap(); - assert_eq!(fs.listing(), vec![("/p/img_1.JPG".into(), "first".into()), ("/p/img_1.xmp".into(), "sc1".into())]); - // case-insensitive, another file under another name: still a collision - let fs = FakeFs::new(true, &[("/p/A.JPG", "a"), ("/p/b.JPG", "b")]); - assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err()); - } - - /// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed - /// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling - /// is the one reported and queued. - #[test] - fn case_insensitive_renames_keep_each_sidecars_spelling() { - let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]); - move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap(); - move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap(); - assert_eq!( - fs.listing(), - vec![ - ("/p/c.jpg".into(), "A".into()), - ("/p/c.jpg.XMP".into(), "full".into()), - ("/p/c.xmp".into(), "stem".into()), - ("/p/d.jpg".into(), "B".into()), - ("/p/d.xmp".into(), "mixed".into()) - ] - ); - // the move of the sidecar's real spelling is the one attempted (and here fails): the photo - // goes back and nothing changed - let before = fs.listing(); - *fs.fail.borrow_mut() = vec!["/p/c.xmp".into()]; - let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err(); - assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}"); - assert!(!e.moved && e.pending_sidecars.is_empty()); - assert_eq!(fs.listing(), before); - } - - /// Issue #105: a rollback that fails is reported, and the library follows the files that kept - /// their new names (an undoable partial rename), so nothing goes missing. - #[test] - fn failed_rollbacks_are_reported_and_followed() { - let fs = FakeFs::new(false, &[("/p/a.jpg", "A"), ("/p/a.xmp", "As"), ("/p/b.jpg", "B"), ("/p/c.jpg", "C")]); - let mut s = Session::new(); - for (i, f) in ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg"].iter().enumerate() { - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(i as u64 + 1, f)) }).unwrap(); - } - let ids = [PhotoId(1), PhotoId(2), PhotoId(3)]; - let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1); - // c can't be renamed (the share went away), and neither can Trip-1 be moved back - fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]); - let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/"); - assert!(err.contains("rename /p/c.jpg"), "{err}"); - assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}"); - assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}"); - assert_eq!( - fs.listing(), - vec![ - ("/p/Trip-1.jpg".into(), "A".into()), - ("/p/Trip-1.xmp".into(), "As".into()), - ("/p/b.jpg".into(), "B".into()), - ("/p/c.jpg".into(), "C".into()) - ] - ); - // the catalog matches the disk - let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source { - Source::File { path } => path.replace('\\', "/"), - Source::Demo { .. } => String::new(), - }; - assert_eq!(path(&s, 1), "/p/Trip-1.jpg"); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().file_name, "Trip-1.jpg"); - assert_eq!((path(&s, 2), path(&s, 3)), ("/p/b.jpg".into(), "/p/c.jpg".into())); - assert_eq!(s.undo.last().map(|e| e.label.as_str()), Some("Rename 1 Photo (partly)")); - - // a sidecar that won't move, and a file that won't go back: the file keeps its new name - // with the sidecars that did move, and says so - let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); - fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]); - let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); - assert!(e.moved, "{e}"); - assert!(e.message.replace('\\', "/").contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}"); - assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]); - // … and when the file does go back, its sidecars follow it - let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); - fs.fail.borrow_mut().push("d.jpg.xmp".to_string()); - let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); - assert!(!e.moved); - assert_eq!(fs.listing().len(), 3); - assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d."))); - } - - #[test] - fn pending_sidecars_preserve_shared_copies_and_do_not_repeat_case_aliases() { - // These are synthetic volume semantics; no host driver or OS runtime is involved. - for ci in [false, true] { - let fs = FakeFs::new(ci, &[("/p/a.CR3", "Raw"), ("/p/a.JPG", "Jpeg"), ("/p/a.XMP", "Shared edits"), ("/p/a.CR3.XMP", "Raw edits")]); - fs.fail.borrow_mut().extend(["/p/a.XMP".into(), "/p/b.CR3".into()]); - let mut error = move_file_with(&fs, "/p/a.CR3", "/p/b.CR3").unwrap_err(); - assert!(error.moved); - assert_eq!(error.pending_sidecars.len(), 2); - assert!(error.pending_sidecars[0].copied); - assert!(!error.pending_sidecars[1].copied); - fs.fail.borrow_mut().clear(); - retry_sidecars(&fs, &mut error.pending_sidecars).unwrap(); - assert!(error.pending_sidecars.is_empty()); - assert_eq!( - fs.listing(), - vec![ - ("/p/a.JPG".into(), "Jpeg".into()), - ("/p/a.XMP".into(), "Shared edits".into()), - ("/p/b.CR3".into(), "Raw".into()), - ("/p/b.CR3.XMP".into(), "Raw edits".into()), - ("/p/b.XMP".into(), "Shared edits".into()), - ] - ); - } - } - - /// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared - /// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it. - #[test] - fn shared_stem_sidecar_is_copied_not_taken() { - let fs = FakeFs::new(false, &[("/p/IMG_1.CR3", "raw"), ("/p/IMG_1.JPG", "jpg"), ("/p/IMG_1.xmp", "shared"), ("/p/IMG_1.CR3.xmp", "own")]); - move_file_with(&fs, "/p/IMG_1.CR3", "/p/Trip_1.CR3").unwrap(); - assert_eq!( - fs.listing(), - vec![ - ("/p/IMG_1.JPG".into(), "jpg".into()), - ("/p/IMG_1.xmp".into(), "shared".into()), - ("/p/Trip_1.CR3".into(), "raw".into()), - ("/p/Trip_1.CR3.xmp".into(), "own".into()), - ("/p/Trip_1.xmp".into(), "shared".into()), - ] - ); - // the JPEG is the last one using it: it moves - move_file_with(&fs, "/p/IMG_1.JPG", "/p/Trip_2.JPG").unwrap(); - assert!(fs.listing().contains(&("/p/Trip_2.xmp".into(), "shared".into()))); - assert!(!fs.exists(Path::new("/p/IMG_1.xmp"))); - // a failure after the copy removes the copy and keeps the original - let fs = FakeFs::new(false, &[("/p/A.CR3", "raw"), ("/p/A.JPG", "jpg"), ("/p/A.xmp", "shared"), ("/p/A.CR3.xmp", "own")]); - fs.fail.borrow_mut().push("A.CR3.xmp".into()); - assert!(move_file_with(&fs, "/p/A.CR3", "/p/B.CR3").is_err()); - assert_eq!(fs.listing().len(), 4); - assert!(fs.listing().contains(&("/p/A.xmp".into(), "shared".into())) && !fs.exists(Path::new("/p/B.xmp"))); - } - - /// Issue #95 on the real disk, whichever kind of volume the temp folder is on. - #[test] - fn case_only_rename_on_disk() { - let dir = std::env::temp_dir().join(format!("lc-rename-case-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let (upper, lower) = (dir.join("IMG_1.JPG"), dir.join("img_1.JPG")); - std::fs::write(&upper, b"first").unwrap(); - let case_sensitive = !lower.exists(); - let p = |q: &Path| q.to_string_lossy().to_string(); - if case_sensitive { - std::fs::write(&lower, b"second").unwrap(); - assert!(move_file(&p(&upper), &p(&lower)).is_err()); - assert_eq!(std::fs::read(&lower).unwrap(), b"second"); - assert_eq!(std::fs::read(&upper).unwrap(), b"first"); - } else { - move_file(&p(&upper), &p(&lower)).unwrap(); - let names: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); - assert_eq!(names, vec!["img_1.JPG".to_string()]); - assert_eq!(std::fs::read(&lower).unwrap(), b"first"); - } - // a plain rename never replaces - std::fs::write(dir.join("other.JPG"), b"other").unwrap(); - assert!(move_file(&p(&dir.join("other.JPG")), &p(&lower)).is_err()); - assert_eq!(std::fs::read(dir.join("other.JPG")).unwrap(), b"other"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn folder_templates_stay_inside_the_destination() { - let mut p = sample_photo(); - assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]); - assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too"); - assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]); - assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]); - // metadata can't add levels or climb out - p.meta.camera = "../../etc/x".into(); - p.meta.title = "..".into(); - assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]); - p.meta.camera = "C:\\Windows".into(); - assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]); - // missing metadata → `unknown`; missing capture time → the import time - p.meta.camera.clear(); - p.captured = None; - assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]); - for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] { - assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected"); - } - for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] { - assert_eq!(folder_template_error(ok), None, "{ok:?}"); - } - // even unchecked, rejected levels are skipped rather than followed - assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]); - } - - /// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is - /// replaced, never left literal, and the help's examples match the documented behaviour. - #[test] - fn token_help_matches_the_implementation() { - let p = sample_photo(); - for t in TOKENS { - for tag in std::iter::once(&t.tag).chain(t.aliases) { - let v = expand_tokens(tag, &p, 1, 1); - assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})"); - assert!(!v.is_empty(), "{tag}: the sample photo should give an example"); - } - } - assert_eq!(token_example("{seq:3}"), "001"); - assert_eq!(token_example("{date}"), "20260114"); - assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848"); - assert_eq!(token_example("{ext}"), "CR3"); - assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3"); - for (d, _) in DATE_DIRECTIVES { - let v = format_date("2026-01-14T05:58:48", d); - assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); - } - assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep"); - assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026"); - // a month that isn't 01–12 gives no name instead of panicking - for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] { - assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}"); - } - assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); - assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); - let json = token_help_json(); - assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len()); - assert_eq!(json["tokens"][0]["example"], "IMG_0042"); - // the command descriptions that list the tokens list all of them - for id in ["photo.rename", "photo.renamePreview", "library.import"] { - let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); - for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) { - assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag); - } - } - } - - #[test] - fn templates() { - let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00"); - p.captured = Some("2026-09-30T14:05:09".into()); - p.meta.camera = "Model X/2".into(); - p.meta.title = "Sunset: beach".into(); - assert_eq!(expand("{name}", &p, 1), "IMG_0042.CR2"); - assert_eq!(expand("Trip-{seq:3}", &p, 7), "Trip-007.CR2"); - assert_eq!(expand("{date}_{name}", &p, 1), "20260930_IMG_0042.CR2"); - assert_eq!(expand("{date:%Y-%m-%d %H.%M.%S}", &p, 1), "2026-09-30 14.05.09.CR2"); - assert_eq!(expand("{camera} {title}", &p, 1), "Model X-2 Sunset- beach.CR2"); - assert_eq!(expand("{unknown}-{seq}", &p, 12), "{unknown}-12.CR2"); - assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name"); - assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components"); - } - - #[test] - fn number_folder_and_metadata_tokens() { - let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF"); - let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00"); - p.meta.lens = "24-70mm F2.8".into(); - p.meta.iso = Some(400); - p.meta.creator = "A. Person".into(); - p.rating = 4; - assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF"); - assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF"); - // no number at the end of the name / no folder: empty - p.file_name = "beach.NEF".into(); - p.source = Source::Demo { scene: 0 }; - assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF"); - // a bare {seq} is padded to the caller's width - assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07"); - } -} +pub(crate) mod tests; diff --git a/crates/engine/src/rename/fs.rs b/crates/engine/src/rename/fs.rs new file mode 100644 index 000000000..352140b38 --- /dev/null +++ b/crates/engine/src/rename/fs.rs @@ -0,0 +1,242 @@ +use super::sidecar::PacketVersion; +use super::*; + +/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). +pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { + let mut v = Vec::new(); + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + let p = crate::sidecar::sidecar_path(path, naming); + v.push((p.with_extension("XMP"), naming, true)); + v.push((p, naming, false)); + } + v +} + +/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of +/// a case-insensitive or case-sensitive volume (and inject failures). +pub(crate) trait MoveFs { + /// Something (file, folder, link) answers to `p`. + fn exists(&self, p: &Path) -> bool; + fn is_file(&self, p: &Path) -> bool; + /// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive + /// volume). Decided by file identity, never by comparing names: on a case-sensitive volume + /// they are two different photos. When in doubt: `false`. + fn same_file(&self, a: &Path, b: &Path) -> bool; + /// Rename `a` to `b`, failing (never replacing) when `b` exists. + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; + /// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists. + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; + fn remove_file(&self, p: &Path) -> std::io::Result<()>; + /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. + /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. + fn stem_sibling(&self, sidecar: &Path) -> Option; + /// The folder lists an entry spelled exactly like `p` (letter case included). On a + /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two + /// apart. When the folder can't be listed: `true`, so nothing is skipped. + fn listed(&self, p: &Path) -> bool { + let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; + let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; + match std::fs::read_dir(dir) { + Ok(entries) => entries.flatten().any(|e| e.file_name() == name), + Err(_) => true, + } + } + /// Content observed in a packet, independent of its pathname or inode. Model volumes + /// override this; disk implementations stream a bounded buffer without retaining the XML. + fn packet_version(&self, path: &Path) -> std::io::Result { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hash = lightcraft_preview::Hasher128::new(); + let mut len = 0u64; + let mut buffer = [0u8; 16 * 1024]; + loop { + let n = match file.read(&mut buffer) { + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Ok(PacketVersion { len, hash: hash.finish() }); + } + let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?; + len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?; + hash.update(bytes); + } + } +} + +/// The real file system. +pub(crate) struct RealFs; + +impl MoveFs for RealFs { + fn exists(&self, p: &Path) -> bool { + std::fs::symlink_metadata(p).is_ok() + } + + fn is_file(&self, p: &Path) -> bool { + p.is_file() + } + + fn same_file(&self, a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false }; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + ma.dev() == mb.dev() && ma.ino() == mb.ino() + } + #[cfg(not(unix))] + { + // no stable file id in std here: `a` and `b` are one file under two spellings when + // they look alike and the folder doesn't list both (on a case-sensitive folder + // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the + // listed one: the answer must not depend on the argument order. + let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; + let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); + if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { + return false; + } + let Ok(entries) = std::fs::read_dir(dir) else { return false }; + let (mut listed_a, mut listed_b) = (false, false); + for e in entries.flatten() { + let name = e.file_name(); + listed_a |= name == name_a; + listed_b |= name == name_b; + } + !(listed_a && listed_b) + } + } + + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // A hard link fails when `b` exists — even if it appeared since the check above — so + // nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back + // to a plain rename after the check. + match std::fs::hard_link(a, b) { + Ok(()) => std::fs::remove_file(a).inspect_err(|_| { + // both names are the same file: dropping the new one loses nothing + let _ = std::fs::remove_file(b); + }), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), + Err(_) => std::fs::rename(a, b) + .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), + } + } + + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed + } + + fn remove_file(&self, p: &Path) -> std::io::Result<()> { + std::fs::remove_file(p) + } + + fn stem_sibling(&self, sidecar: &Path) -> Option { + crate::import_move::sibling_photo(sidecar) + } +} + +/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original +/// byte for byte, and only then remove the original. A copy that doesn't match is removed. +fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { + fs.copy_no_replace(a, b)?; + if !same_contents(a, b).unwrap_or(false) { + let _ = std::fs::remove_file(b); + return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); + } + std::fs::remove_file(a).inspect_err(|_| { + // the original stays: drop the copy so the file isn't in two places + let _ = std::fs::remove_file(b); + }) +} + +/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so +/// a large raw or video is never read into memory whole. +pub(super) fn same_contents(a: &Path, b: &Path) -> std::io::Result { + const CHUNK: usize = 1 << 20; + let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); + if fa.metadata()?.len() != fb.metadata()?.len() { + return Ok(false); + } + let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); + loop { + let n = read_full(&mut fa, &mut ba)?; + let m = read_full(&mut fb, &mut bb)?; + if n != m || ba.get(..n) != bb.get(..m) { + return Ok(false); + } + if n == 0 { + return Ok(true); + } + } +} + +/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer +/// only at the end of the file). +fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { + match r.read(rest) { + Ok(0) => break, + Ok(k) => filled = filled.saturating_add(k), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(filled) +} + +/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive +/// volume): the rename must go through a temporary name. +pub(super) fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { + from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to) +} + +/// Windows accepts both separators; spelling differences must not hide file identity. +pub(super) fn folded_path(path: &str) -> String { + if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() } +} + +/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a +/// unique temporary name so case-insensitive volumes see a real change. +pub(super) fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> { + if !is_respelling(fs, a, b) { + return fs.rename_no_replace(a, b); + } + let tmp = (0..1000u32) + .map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id()))) + .find(|t| !fs.exists(t)) + .ok_or_else(|| std::io::Error::other("no free temporary name"))?; + fs.rename_no_replace(a, &tmp)?; + fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) { + Ok(()) => e, + Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())), + }) +} diff --git a/crates/engine/src/rename/moves.rs b/crates/engine/src/rename/moves.rs new file mode 100644 index 000000000..6f7c245c9 --- /dev/null +++ b/crates/engine/src/rename/moves.rs @@ -0,0 +1,168 @@ +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::*; + +/// Why [`move_file`] failed, and what it left behind. +#[derive(Clone, Debug, PartialEq)] +pub struct MoveError { + /// What went wrong, including every step that could not be undone and where that file is now. + pub message: String, + /// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file + /// back failed too. Its sidecars that did move stay with it. + pub moved: bool, + pub(crate) pending_sidecars: Vec, +} + +impl std::fmt::Display for MoveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the +/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]). +pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> { + move_file_with(&RealFs, from, to) +} + +/// [`move_file`] on the file system `fs`. +pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> { + if Path::new(from) == Path::new(to) { + return Ok(()); + } + let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() }; + let (f, t) = (Path::new(from), Path::new(to)); + // `to` may be this very file spelled differently (case-insensitive volume); any other file + // there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced + if fs.exists(t) && !is_respelling(fs, f, t) { + return Err(fail(format!("{to} already exists"))); + } + // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the + // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. + let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); + let mut packets: Vec = + sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); + move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; + let mut done: Vec = Vec::new(); + let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { + let mut dst = crate::sidecar::sidecar_path(to, naming); + if upper { + dst = dst.with_extension("XMP"); + } + // A stem sidecar another photo still uses is copied, not taken away (issue #92). + let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); + SidecarMove { from: sc, to: dst, copied, companions: Vec::new() } + }); + while let Some(m) = remaining.next() { + if !m.needed(fs) { + continue; // never overwrite: the old sidecar stays where it was + } + let aliases = packet_aliases(&packets, fs, &m.from); + if let Err(e) = m.apply(fs) { + let mut message = format!("rename {}: {e}", m.from.display()); + // The file first: if it can't go back, sidecars that moved stay with it. + if let Err(e) = move_one(fs, t, f) { + message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}")); + if !done.is_empty() { + let names: Vec = done.iter().map(|m| m.to.display().to_string()).collect(); + message.push_str(&format!(" with its sidecar {}", names.join(", "))); + } + let mut pending_sidecars = vec![m]; + for m in remaining.filter(|m| m.needed(fs)) { + // The two case spellings can name one file. Keep one operation for + // those aliases, while retaining distinct sidecars on sensitive volumes. + let alias = pending_sidecars.iter().any(|p| { + folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy()) + && folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy()) + && fs.same_file(&p.from, &m.from) + }); + if !alias { + pending_sidecars.push(m); + } + } + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: true, pending_sidecars }); + } + let mut pending_sidecars = Vec::new(); + for m in done.iter().rev() { + if m.copied { + // The original is still there: a leftover copy loses nothing. + if fs.remove_file(&m.to).is_ok() { + packets.retain(|p| p.path != m.to); + } + } else { + let aliases = packet_aliases(&packets, fs, &m.to); + if let Err(e) = move_one(fs, &m.to, &m.from) { + message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display())); + pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() }); + } else { + advance_packets(&mut packets, &aliases, &m.from, false, fs); + } + } + } + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: false, pending_sidecars }); + } + advance_packets(&mut packets, &aliases, &m.to, m.copied, fs); + done.push(m); + } + Ok(()) +} + +/// What a failed batch of moves ([`move_all`]) left behind. +#[derive(Debug)] +pub(crate) struct BatchMoveError { + /// The first failure and every move that could not be undone. + pub message: String, + /// Moves that stayed done, (from, to): those files are at `to` now. + pub stuck: Vec<(String, String)>, + /// Sidecars still separated from the path where their photo ended up, in either direction. + pub pending_sidecars: Vec, +} + +/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone. +/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored. +pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> { + let mut done: Vec<&(String, String)> = Vec::new(); + for m in moves { + let Err(e) = move_file_with(fs, &m.0, &m.1) else { + done.push(m); + continue; + }; + let mut message = e.message; + let mut pending_sidecars = e.pending_sidecars; + let mut stuck = Vec::new(); + if e.moved { + stuck.push(m.clone()); + } + for d in done.iter().rev() { + if let Err(mut e) = move_file_with(fs, &d.1, &d.0) { + message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message)); + if !e.moved { + stuck.push((*d).clone()); + } + pending_sidecars.append(&mut e.pending_sidecars); + } + } + return Err(BatchMoveError { message, stuck, pending_sidecars }); + } + Ok(()) +} + +/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward. +pub(super) fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec) { + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut final_ops = HashMap::new(); + while let Some(op) = pending.pop() { + match op { + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id), + _ => {} + } + } + out.extend(order.into_iter().filter_map(|id| { + let op = final_ops.get(&id).copied()?; + matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone()) + })); +} diff --git a/crates/engine/src/rename/plan.rs b/crates/engine/src/rename/plan.rs new file mode 100644 index 000000000..331297ba4 --- /dev/null +++ b/crates/engine/src/rename/plan.rs @@ -0,0 +1,295 @@ +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::*; + +/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) +/// rename once. No file-system calls. +pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec> { + let mut seen_paths: HashSet = HashSet::new(); + ids.iter() + .filter_map(|id| cat.photo(*id)) + .filter(|p| match &p.source { + Source::File { path } => seen_paths.insert(path.clone()), + _ => true, + }) + .cloned() + .collect() +} + +/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check +/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow +/// drive). Whether a case-only change names this very file is decided on the disk. +pub fn plan_rename_photos(photos: &[std::sync::Arc], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec { + plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b)) +} + +/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether +/// two spellings name one file (see [`MoveFs`]). +fn plan_rename_core( + photos: &[std::sync::Arc], + template: &str, + start: usize, + exists: &dyn Fn(&Path) -> bool, + same_file: &dyn Fn(&Path, &Path) -> bool, +) -> Vec { + let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch + let mut out = Vec::new(); + for (i, p) in photos.iter().enumerate() { + let seq = start.saturating_add(i); + let want = expand(template, p, seq); + let (stem, ext) = split_ext(&want); + let (stem, ext) = (stem.to_string(), ext.to_string()); + let candidate = |k: usize| { + if k == 0 { + want.clone() + } else if ext.is_empty() { + format!("{stem}-{k}") + } else { + format!("{stem}-{k}.{ext}") + } + }; + let (to, to_path) = match &p.source { + Source::File { path } => { + let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); + let mut k = 0; + loop { + let name = candidate(k); + let tp = dir.join(&name).to_string_lossy().to_string(); + let key = folded_path(&tp); + // this very file, maybe spelled differently (case-insensitive volume)? By + // identity: on a case-sensitive volume `img_1.jpg` may be another photo + let (from, to) = (Path::new(path), Path::new(&tp)); + let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to)); + // free: not claimed in this batch and not on disk (unless it is this very file). + // A file this batch moves away still counts as taken: simple and safe. + if !taken.contains(&key) && (same || !exists(to)) { + taken.insert(key); + break (name, Some(tp)); + } + k += 1; + } + } + Source::Demo { .. } => { + let mut k = 0; + loop { + let name = candidate(k); + if taken.insert(format!("demo:{}", name.to_lowercase())) { + break (name, None); + } + k += 1; + } + } + }; + let from_path = match &p.source { + Source::File { path } => Some(path.clone()), + _ => None, + }; + out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path }); + } + out +} + +impl Session { + /// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions. + pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec { + self.plan_rename_with(&RealFs, ids, template, start) + } + + /// [`Session::plan_rename`] on the file system `fs`. + pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec { + plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b)) + } + + /// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op + /// that updates the photos (and their virtual copies). + /// + /// When a failure can't be fully rolled back, the files that kept their new names are + /// committed as a smaller undoable rename, so the library points at them (nothing goes + /// missing), and the error lists them (old → new). + pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result { + self.apply_rename_with(&RealFs, plans) + } + + /// Complete retained repairs before a file or folder change can stale their exact paths. + pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } + retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?; + } + Ok(()) + } + + pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + self.validate_sidecar_packets_for_paths(paths, fs)?; + retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?; + } + Ok(()) + } + + /// A healthy shared packet may be saved while another photo's repair is unavailable. + /// Check its retained content too, without forcing that unrelated physical operation. + pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + if paths.iter().any(|path| repair.touches(fs, path)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } else { + for packet in &repair.companions { + if paths.iter().any(|path| fs.same_file(path, &packet.path)) { + packet.validate(fs).map_err(EngineError::Other)?; + } + } + } + } + Ok(()) + } + + /// Only a successful application write updates a retained content version. Atomic writes + /// replace inodes; aliases that now resolve to the written path observe the new content. + pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) { + let version = PacketVersion::of(bytes); + for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) { + for packet in &mut repair.companions { + if RealFs.same_file(path, &packet.path) { + packet.version = Some(version); + } + } + } + } + + /// [`Session::apply_rename`] on the file system `fs`. + pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { + self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) + } + + /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars + /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the + /// photos (and their virtual copies) at the new paths. Undo moves the files back. + pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { + // A partly completed rename can already have the requested photo name. Repair + // its sidecars before treating that empty move list as a successful retry. + self.repair_sidecars_with(fs)?; + let mut ops = Vec::new(); + let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); + for p in self.catalog.photos() { + let plan = match &p.source { + Source::File { path } => by_path.get(path.as_str()).copied(), + Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()), + }; + let Some(plan) = plan else { continue }; + let source = match &plan.to_path { + Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(), + Some(t) => Source::File { path: t.clone() }, + None => p.source.clone(), + }; + if plan.to != p.file_name || source != p.source { + ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source }); + } + } + let n = ops.len(); + let op = Op::Batch { ops }; + let moves: Vec<(String, String)> = plans + .iter() + .filter_map(|pl| match (&pl.from_path, &pl.to_path) { + (Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())), + _ => None, + }) + .collect(); + if let Err(e) = move_all(fs, &moves) { + return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); + } + if n > 0 + && let Err(err) = self.commit(&label(n), op) + { + let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); + if let Err(e) = move_all(fs, &back) { + return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message))); + } + return Err(err); + } + Ok(n) + } + + /// After a failed batch of moves for `op`: point the photos whose files stayed moved at + /// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged + /// change), so the library matches the disk. Returns the message and, for a history retry, + /// the correction's inverse file ops (so a retry doesn't lose the original paths). + pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec) { + let mut msg = e.message; + let pending_sidecars = e.pending_sidecars; + if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) { + return (msg, Vec::new()); + } + let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect(); + let mut keep = Vec::new(); + set_file_ops_to(op, &to, &mut keep); + let list: Vec = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect(); + let n = e.stuck.len(); + let s = if n == 1 { "" } else { "s" }; + let fix = Op::Batch { ops: keep }; + let mut inverses = Vec::new(); + let applied = if undoable { + let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") }; + self.commit(&label, fix).map(|()| { + if let Some(entry) = self.undo.last_mut() { + entry.retry_sidecars.extend(pending_sidecars); + } + }) + } else { + match self.apply_catalog(fix.clone()) { + Ok(inv) => { + inverses = match inv { + Op::Batch { ops } => ops, + op => vec![op], + }; + self.pending_log.push(fix); + Ok(()) + } + Err(e) => Err(e), + } + }; + match applied { + Ok(()) if n > 0 => msg + .push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))), + Ok(()) => {} + Err(err) => msg.push_str(&format!( + ". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}", + list.join(", ") + )), + } + (msg, inverses) + } + + /// Physical moves to the final `SetFile` destination of each photo. A merged history + /// batch can rename the same photo repeatedly; intermediate catalog paths need no file. + pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut targets = HashMap::new(); + while let Some(op) = pending.pop() { + match op { + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => { + order.push(*id); + } + _ => {} + } + } + // Virtual copies share one physical move, keeping the original batch order. + let mut seen = HashSet::new(); + order + .into_iter() + .filter_map(|id| { + let Source::File { path: to } = targets.get(&id).copied()? else { return None }; + let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None }; + (Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone())) + }) + .collect() + } +} diff --git a/crates/engine/src/rename/sidecar.rs b/crates/engine/src/rename/sidecar.rs new file mode 100644 index 000000000..24ebcd3f9 --- /dev/null +++ b/crates/engine/src/rename/sidecar.rs @@ -0,0 +1,174 @@ +use super::fs::{MoveFs, is_respelling, move_one}; +use super::*; + +/// 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. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SidecarMove { + pub from: PathBuf, + pub to: PathBuf, + pub(super) copied: bool, + /// Observed packets in the partial move, including the separated source. Absent planned + /// destinations are never owned; a completed repair advances these observations. + pub(super) companions: Vec, +} + +/// A content version, not an assertion that a pathname still names the same packet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PacketVersion { + pub(super) len: u64, + pub(super) hash: lightcraft_preview::Hash128, +} + +impl PacketVersion { + pub(super) fn of(bytes: &[u8]) -> Self { + Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct RetainedPacket { + pub(super) path: PathBuf, + // An unreadable observation remains untrusted even if a later read succeeds. + pub(super) version: Option, +} + +impl RetainedPacket { + pub(super) fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + if self.version.is_none() { + return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display())); + } + let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?; + if self.version != Some(current) { + return Err(format!( + "retained XMP {} changed since the partial move; packet ownership must be resolved before retrying", + self.path.display() + )); + } + Ok(()) + } +} + +/// Advance only packets whose operation actually succeeded. A copy retains its original; +/// a move retires vanished source aliases and adds the observed destination. +pub(super) fn advance_packets(packets: &mut Vec, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) { + let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return }; + if !copied { + packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path)); + } + packet.path = to.to_path_buf(); + if !packets.iter().any(|p| p.path == packet.path) { + packets.push(packet); + } +} + +pub(super) fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec { + packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect() +} + +impl SidecarMove { + fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + for packet in &self.companions { + packet.validate(fs)?; + } + Ok(()) + } + + /// Validate a queued retry and return the copy decision for this physical operation. + pub(super) fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result { + self.validate_packets(fs)?; + let respelling = is_respelling(fs, &self.from, &self.to); + // Batch rollback may have restored another photo using this source packet. + let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some()); + if fs.exists(&self.to) && (copied || !respelling) { + return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display())); + } + Ok(copied) + } + + pub(super) fn needed(&self, fs: &dyn MoveFs) -> bool { + fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to))) + } + + pub(super) fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool { + let same_path = |endpoint: &Path| { + endpoint == path + || fs.same_file(endpoint, path) + || (endpoint.file_name() == path.file_name() + && endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b))) + }; + same_path(&self.from) || same_path(&self.to) + } + + pub(super) fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> { + if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) } + } +} + +/// Complete known unfinished sidecars before moving any more photos. Completed operations +/// leave the queue; a failure retains that operation and the rest, without replacing a file. +pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec) -> std::result::Result<(), String> { + validate_sidecars(fs, pending)?; + while !pending.is_empty() { + apply_repair(fs, pending, 0)?; + } + Ok(()) +} + +/// Preflight a detached history entry before global queues are allowed to retire. The +/// caller can validate without mutating it; retry revalidates before each physical operation. +pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> { + for repair in pending { + repair.validate_repair(fs)?; + } + Ok(()) +} + +/// Finish one operation and transfer its observed content to the remaining repairs. Do not +/// infer ownership by reading an occupied destination or by retaining a planned pathname. +fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec, i: usize) -> std::result::Result<(), String> { + let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) }; + repair.copied = repair.validate_repair(fs)?; + let aliases = packet_aliases(&repair.companions, fs, &repair.from); + repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?; + for remaining in pending.iter_mut() { + advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs); + } + pending.remove(i); + Ok(()) +} + +/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals +/// must not block healthy metadata saves. Completed repairs retire; failures stay queued. +pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec, paths: &[PathBuf]) -> std::result::Result<(), String> { + for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) { + repair.validate_repair(fs)?; + } + let mut i = 0; + while let Some(m) = pending.get(i) { + if paths.iter().any(|path| m.touches(fs, path)) { + // Path/alias equality only locates an observation; validation above proves its + // content. A newly occupied, previously absent peer destination is a collision. + if let Some(path) = paths.iter().find(|path| { + fs.exists(path) + && !fs.same_file(path, &m.from) + && !fs.same_file(path, &m.to) + && !m.companions.iter().any(|known| fs.same_file(path, &known.path)) + }) { + return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display())); + } + apply_repair(fs, pending, i)?; + } else { + i += 1; + } + } + Ok(()) +} + +/// Every record came from an actual packet observed before this move. Successful physical +/// operations have already advanced its path; missing planned destinations are not recorded. +pub(super) fn note_companions(pending: &mut [SidecarMove], packets: Vec) { + for repair in pending { + repair.companions.clone_from(&packets); + } +} diff --git a/crates/engine/src/rename/tests.rs b/crates/engine/src/rename/tests.rs new file mode 100644 index 000000000..d8ef8de10 --- /dev/null +++ b/crates/engine/src/rename/tests.rs @@ -0,0 +1,590 @@ +use super::*; +use std::cell::RefCell; + +/// Windows has no file id in std: two spellings of one file are the same file whichever is +/// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the +/// known `Trip.xmp`), and only the spelling on disk is listed. +#[cfg(windows)] +#[test] +fn same_file_spellings_are_symmetric_on_windows() { + let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp")); + std::fs::write(&real, b"packet").unwrap(); + std::fs::write(&other, b"packet").unwrap(); + assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real)); + assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias)); + assert!(RealFs.listed(&real) && !RealFs.listed(&alias)); + std::fs::remove_dir_all(&dir).unwrap(); +} + +/// The cross-volume move's check compares files chunk by chunk: equal files across several +/// chunks, a difference in the last chunk, different lengths, a missing file. +#[test] +fn same_contents_compares_in_chunks() { + let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let big: Vec = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect(); + let (a, b) = (dir.join("a"), dir.join("b")); + std::fs::write(&a, &big).unwrap(); + std::fs::write(&b, &big).unwrap(); + assert!(same_contents(&a, &b).unwrap()); + let mut other = big.clone(); + *other.last_mut().unwrap() ^= 1; + std::fs::write(&b, &other).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&b, &big[..big.len() - 1]).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&a, b"").unwrap(); + std::fs::write(&b, b"").unwrap(); + assert!(same_contents(&a, &b).unwrap()); + assert!(same_contents(&a, &dir.join("missing")).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or +/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail. +pub(crate) struct FakeFs { + pub ci: bool, + pub files: RefCell)>>, + pub fail: RefCell>, +} + +impl FakeFs { + pub fn new(ci: bool, files: &[(&str, &str)]) -> FakeFs { + FakeFs { + ci, + files: RefCell::new(files.iter().map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())).collect()), + fail: RefCell::new(Vec::new()), + } + } + /// The model volume spells paths with `/`; the code under test joins them with the host's separator. + fn norm(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") + } + fn idx(&self, p: &Path) -> Option { + let p = Self::norm(p); + self.files.borrow().iter().position(|(f, _)| { + let f = f.replace('\\', "/"); + if self.ci { folded_path(&f) == folded_path(&p) } else { f == p } + }) + } + /// The listing: (exact path, contents), sorted. + pub fn listing(&self) -> Vec<(String, String)> { + let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect(); + v.sort(); + v + } +} + +impl MoveFs for FakeFs { + fn packet_version(&self, path: &Path) -> std::io::Result { + let i = self.idx(path).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such packet"))?; + self.files.borrow().get(i).map(|(_, bytes)| PacketVersion::of(bytes)).ok_or_else(|| std::io::Error::other("packet disappeared")) + } + fn exists(&self, p: &Path) -> bool { + self.idx(p).is_some() + } + fn is_file(&self, p: &Path) -> bool { + self.exists(p) + } + fn same_file(&self, a: &Path, b: &Path) -> bool { + self.idx(a).is_some() && self.idx(a) == self.idx(b) + } + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { + return Err(std::io::Error::other("injected failure")); + } + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); + } + let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; + self.files.borrow_mut()[i].0 = Self::norm(b); + Ok(()) + } + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { + return Err(std::io::Error::other("injected failure")); + } + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); + } + let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; + let c = self.files.borrow()[i].1.clone(); + self.files.borrow_mut().push((Self::norm(b), c)); + Ok(()) + } + fn remove_file(&self, p: &Path) -> std::io::Result<()> { + let i = self.idx(p).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; + self.files.borrow_mut().remove(i); + Ok(()) + } + fn listed(&self, p: &Path) -> bool { + let p = Self::norm(p); + self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p) + } + fn stem_sibling(&self, sidecar: &Path) -> Option { + let stem = Self::norm(&sidecar.with_extension("")); + self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| { + let p = Path::new(f); + Self::norm(&p.with_extension("")) == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp")) + }) + } +} + +fn file_photo(id: u64, path: &str) -> Photo { + let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // the host's separator, so `dir.join(name)` in the planner spells the same path the same way + let path = path.replace('/', std::path::MAIN_SEPARATOR_STR); + Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00") +} + +/// Compare the unfinished operation and every observed packet after partial progress. +/// Companion order is incidental; paths and recorded content versions are exact. +pub(crate) fn assert_repair_packets(actual: &SidecarMove, unfinished: &SidecarMove, packets: &[(PathBuf, &[u8])]) { + let mut expected = unfinished.clone(); + expected.companions = + packets.iter().map(|(path, bytes)| RetainedPacket { path: path.clone(), version: Some(PacketVersion::of(bytes)) }).collect(); + let mut actual = actual.clone(); + actual.companions.sort_by(|a, b| a.path.cmp(&b.path)); + expected.companions.sort_by(|a, b| a.path.cmp(&b.path)); + assert_eq!(actual, expected); +} + +#[test] +fn folder_moves_repair_retained_sidecars_before_changing_paths() { + for command in ["folder.rename", "folder.move"] { + for queue in ["pending", "undo", "redo"] { + for (naming, copied) in [(SidecarNaming::Stem, false), (SidecarNaming::Full, false), (SidecarNaming::Stem, true)] { + let root = crate::tests_xmp::temp_dir("folder-sidecar-repair"); + let from = root.join("Trip"); + std::fs::create_dir_all(&from).unwrap(); + let photo = from.join("b.jpg"); + std::fs::write(&photo, b"Photo bytes").unwrap(); + let old_sidecar = crate::sidecar::sidecar_path(&from.join("a.jpg").to_string_lossy(), naming); + let sidecar = crate::sidecar::sidecar_path(&photo.to_string_lossy(), naming); + std::fs::write(&old_sidecar, b"Retained edits").unwrap(); + // An ordinary occupied destination must block repair without replacing its bytes. + std::fs::write(&sidecar, b"Other edits").unwrap(); + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, &photo.to_string_lossy())) }).unwrap(); + if copied { + let sibling = from.join("a.raw"); + std::fs::write(&sibling, b"Shared original").unwrap(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(2, &sibling.to_string_lossy())) }).unwrap(); + } + s.commit("Rating", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap(); + if queue == "redo" { + s.undo_step().unwrap(); + } + let repair = SidecarMove { + from: old_sidecar.clone(), + to: sidecar.clone(), + copied, + companions: vec![RetainedPacket { path: old_sidecar.clone(), version: Some(RealFs.packet_version(&old_sidecar).unwrap()) }], + }; + match queue { + "undo" => s.undo.last_mut().unwrap().retry_sidecars.push(repair), + "redo" => s.redo.last_mut().unwrap().retry_sidecars.push(repair), + _ => s.pending_sidecar_repairs.push(repair), + } + let params = if command == "folder.rename" { + serde_json::json!({"path": from, "name": "Holiday"}) + } else { + serde_json::json!({"path": from, "into": root.join("Archive")}) + }; + let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") }; + let before = s.catalog.to_snapshot(); + let history = (s.undo.len(), s.redo.len()); + assert!(s.execute(command, ¶ms).is_err()); + assert!(from.is_dir() && !to.exists()); + assert_eq!(s.catalog.to_snapshot(), before); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(std::fs::read(&old_sidecar).unwrap(), b"Retained edits"); + assert_eq!(std::fs::read(&sidecar).unwrap(), b"Other edits"); + std::fs::remove_file(&sidecar).unwrap(); + s.execute(command, ¶ms).unwrap(); + assert!(s.pending_sidecar_repairs.is_empty()); + assert!(s.undo.iter().chain(&s.redo).all(|e| e.retry_sidecars.is_empty())); + let moved_sidecar = to.join(sidecar.file_name().unwrap()); + assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); + assert_eq!(to.join(old_sidecar.file_name().unwrap()).exists(), copied); + for (step, current) in [("edit.undo", &from), ("edit.redo", &to)] { + s.execute(step, &serde_json::json!({})).unwrap(); + assert_eq!( + s.catalog.photo(PhotoId(1)).unwrap().source, + Source::File { path: current.join("b.jpg").to_string_lossy().into_owned() } + ); + assert_eq!(std::fs::read(current.join(sidecar.file_name().unwrap())).unwrap(), b"Retained edits"); + assert_eq!(current.join(old_sidecar.file_name().unwrap()).exists(), copied); + } + // Explicit IDs choose targets after the command's selection gate. + s.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + s.execute("photo.rename", &serde_json::json!({"ids": [1], "template": "Latest"})).unwrap(); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert!(to.join("b.jpg").is_file()); + assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); + s.execute("edit.redo", &serde_json::json!({})).unwrap(); + assert!(to.join("Latest.jpg").is_file()); + let final_sidecar = crate::sidecar::sidecar_path(&to.join("Latest.jpg").to_string_lossy(), naming); + assert_eq!(std::fs::read(final_sidecar).unwrap(), b"Retained edits"); + std::fs::remove_dir_all(root).unwrap(); + } + } + } +} + +#[cfg(windows)] +#[test] +fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() { + let fs = FakeFs::new(true, &[("N:/photos/IMG.jpg", "image"), ("N:/photos/IMG.xmp", "edits"), ("N:/photos/other.jpg", "other")]); + // Any attempt to move the unchanged photo (including rollback) must fail. + fs.fail.borrow_mut().push("IMG".into()); + let mut s = Session::new(); + // spelled with `/` on purpose (not `file_photo`, which uses the host's separator) + for (id, path) in [(1, "N:/photos/IMG.jpg"), (2, "N:/photos/other.jpg")] { + let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + let before = fs.listing(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1); + assert_ne!(plans[0].from_path, plans[0].to_path, "fixture exercises different separator spelling"); + assert_eq!(s.apply_rename_with(&fs, &plans).unwrap(), 0); + assert_eq!(fs.listing(), before); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "N:/photos/IMG.jpg".into() }); + assert!(s.undo.is_empty()); + move_file_with(&fs, "N:/photos/IMG.jpg", "N:/photos\\IMG.jpg").unwrap(); + let separator_only = Op::SetFile { id: PhotoId(1), file_name: "IMG.jpg".into(), source: Source::File { path: "N:/photos\\IMG.jpg".into() } }; + assert!(s.file_moves(&separator_only).is_empty()); + + // A batch may combine that no-op with a genuine rename, without touching IMG or its sidecar. + let mut batch = plans; + batch.extend(s.plan_rename_with(&fs, &[PhotoId(2)], "renamed", 1)); + assert_eq!(s.apply_rename_with(&fs, &batch).unwrap(), 1); + assert_eq!( + fs.listing(), + vec![ + ("N:/photos/IMG.jpg".into(), "image".into()), + ("N:/photos/IMG.xmp".into(), "edits".into()), + ("N:/photos/renamed.jpg".into(), "other".into()) + ] + ); + let undo = &s.undo.last().unwrap().op; + let moves = s.file_moves(undo); + assert_eq!(moves.len(), 1); + assert_eq!(Path::new(&moves[0].1), Path::new("N:/photos/other.jpg")); + move_all(&fs, &moves).unwrap(); + s.catalog.apply(undo.clone()).unwrap(); + assert_eq!(fs.listing(), before); +} + +/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never +/// replaces a different file that differs only by case on a case-sensitive one. +#[test] +fn case_only_renames_never_replace_another_file() { + // case-sensitive: IMG_1.JPG and img_1.JPG are two photos + let fs = FakeFs::new(false, &[("/p/IMG_1.JPG", "first"), ("/p/img_1.JPG", "second"), ("/p/IMG_1.xmp", "sc1"), ("/p/img_1.xmp", "sc2")]); + assert!(move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap_err().message.contains("already exists")); + assert_eq!(fs.listing().len(), 4); + assert!(fs.listing().contains(&("/p/img_1.JPG".into(), "second".into()))); + // the planner picks a free name instead + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); + assert_eq!(plans[0].to, "img_1-1.JPG"); + move_file_with(&fs, "/p/IMG_1.JPG", plans[0].to_path.as_deref().unwrap()).unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/img_1-1.JPG".into(), "first".into()), + ("/p/img_1-1.xmp".into(), "sc1".into()), + ("/p/img_1.JPG".into(), "second".into()), + ("/p/img_1.xmp".into(), "sc2".into()) + ] + ); + // case-sensitive, target free: a plain rename + let fs = FakeFs::new(false, &[("/p/IMG_2.JPG", "x")]); + move_file_with(&fs, "/p/IMG_2.JPG", "/p/img_2.JPG").unwrap(); + assert_eq!(fs.listing(), vec![("/p/img_2.JPG".into(), "x".into())]); + + // case-insensitive: the "existing" target is the photo itself → renamed via a temp name + let fs = FakeFs::new(true, &[("/p/IMG_1.JPG", "first"), ("/p/IMG_1.xmp", "sc1")]); + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); + assert_eq!(plans[0].to, "img_1.JPG", "not a collision with itself"); + move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap(); + assert_eq!(fs.listing(), vec![("/p/img_1.JPG".into(), "first".into()), ("/p/img_1.xmp".into(), "sc1".into())]); + // case-insensitive, another file under another name: still a collision + let fs = FakeFs::new(true, &[("/p/A.JPG", "a"), ("/p/b.JPG", "b")]); + assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err()); +} + +/// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed +/// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling +/// is the one reported and queued. +#[test] +fn case_insensitive_renames_keep_each_sidecars_spelling() { + let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]); + move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap(); + move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/c.jpg".into(), "A".into()), + ("/p/c.jpg.XMP".into(), "full".into()), + ("/p/c.xmp".into(), "stem".into()), + ("/p/d.jpg".into(), "B".into()), + ("/p/d.xmp".into(), "mixed".into()) + ] + ); + // the move of the sidecar's real spelling is the one attempted (and here fails): the photo + // goes back and nothing changed + let before = fs.listing(); + *fs.fail.borrow_mut() = vec!["/p/c.xmp".into()]; + let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err(); + assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}"); + assert!(!e.moved && e.pending_sidecars.is_empty()); + assert_eq!(fs.listing(), before); +} + +/// Issue #105: a rollback that fails is reported, and the library follows the files that kept +/// their new names (an undoable partial rename), so nothing goes missing. +#[test] +fn failed_rollbacks_are_reported_and_followed() { + let fs = FakeFs::new(false, &[("/p/a.jpg", "A"), ("/p/a.xmp", "As"), ("/p/b.jpg", "B"), ("/p/c.jpg", "C")]); + let mut s = Session::new(); + for (i, f) in ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg"].iter().enumerate() { + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(i as u64 + 1, f)) }).unwrap(); + } + let ids = [PhotoId(1), PhotoId(2), PhotoId(3)]; + let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1); + // c can't be renamed (the share went away), and neither can Trip-1 be moved back + fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]); + let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/"); + assert!(err.contains("rename /p/c.jpg"), "{err}"); + assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}"); + assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}"); + assert_eq!( + fs.listing(), + vec![ + ("/p/Trip-1.jpg".into(), "A".into()), + ("/p/Trip-1.xmp".into(), "As".into()), + ("/p/b.jpg".into(), "B".into()), + ("/p/c.jpg".into(), "C".into()) + ] + ); + // the catalog matches the disk + let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source { + Source::File { path } => path.replace('\\', "/"), + Source::Demo { .. } => String::new(), + }; + assert_eq!(path(&s, 1), "/p/Trip-1.jpg"); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().file_name, "Trip-1.jpg"); + assert_eq!((path(&s, 2), path(&s, 3)), ("/p/b.jpg".into(), "/p/c.jpg".into())); + assert_eq!(s.undo.last().map(|e| e.label.as_str()), Some("Rename 1 Photo (partly)")); + + // a sidecar that won't move, and a file that won't go back: the file keeps its new name + // with the sidecars that did move, and says so + let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); + fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]); + let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); + assert!(e.moved, "{e}"); + assert!(e.message.replace('\\', "/").contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}"); + assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]); + // … and when the file does go back, its sidecars follow it + let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); + fs.fail.borrow_mut().push("d.jpg.xmp".to_string()); + let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); + assert!(!e.moved); + assert_eq!(fs.listing().len(), 3); + assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d."))); +} + +#[test] +fn pending_sidecars_preserve_shared_copies_and_do_not_repeat_case_aliases() { + // These are synthetic volume semantics; no host driver or OS runtime is involved. + for ci in [false, true] { + let fs = FakeFs::new(ci, &[("/p/a.CR3", "Raw"), ("/p/a.JPG", "Jpeg"), ("/p/a.XMP", "Shared edits"), ("/p/a.CR3.XMP", "Raw edits")]); + fs.fail.borrow_mut().extend(["/p/a.XMP".into(), "/p/b.CR3".into()]); + let mut error = move_file_with(&fs, "/p/a.CR3", "/p/b.CR3").unwrap_err(); + assert!(error.moved); + assert_eq!(error.pending_sidecars.len(), 2); + assert!(error.pending_sidecars[0].copied); + assert!(!error.pending_sidecars[1].copied); + fs.fail.borrow_mut().clear(); + retry_sidecars(&fs, &mut error.pending_sidecars).unwrap(); + assert!(error.pending_sidecars.is_empty()); + assert_eq!( + fs.listing(), + vec![ + ("/p/a.JPG".into(), "Jpeg".into()), + ("/p/a.XMP".into(), "Shared edits".into()), + ("/p/b.CR3".into(), "Raw".into()), + ("/p/b.CR3.XMP".into(), "Raw edits".into()), + ("/p/b.XMP".into(), "Shared edits".into()), + ] + ); + } +} + +/// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared +/// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it. +#[test] +fn shared_stem_sidecar_is_copied_not_taken() { + let fs = FakeFs::new(false, &[("/p/IMG_1.CR3", "raw"), ("/p/IMG_1.JPG", "jpg"), ("/p/IMG_1.xmp", "shared"), ("/p/IMG_1.CR3.xmp", "own")]); + move_file_with(&fs, "/p/IMG_1.CR3", "/p/Trip_1.CR3").unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/IMG_1.JPG".into(), "jpg".into()), + ("/p/IMG_1.xmp".into(), "shared".into()), + ("/p/Trip_1.CR3".into(), "raw".into()), + ("/p/Trip_1.CR3.xmp".into(), "own".into()), + ("/p/Trip_1.xmp".into(), "shared".into()), + ] + ); + // the JPEG is the last one using it: it moves + move_file_with(&fs, "/p/IMG_1.JPG", "/p/Trip_2.JPG").unwrap(); + assert!(fs.listing().contains(&("/p/Trip_2.xmp".into(), "shared".into()))); + assert!(!fs.exists(Path::new("/p/IMG_1.xmp"))); + // a failure after the copy removes the copy and keeps the original + let fs = FakeFs::new(false, &[("/p/A.CR3", "raw"), ("/p/A.JPG", "jpg"), ("/p/A.xmp", "shared"), ("/p/A.CR3.xmp", "own")]); + fs.fail.borrow_mut().push("A.CR3.xmp".into()); + assert!(move_file_with(&fs, "/p/A.CR3", "/p/B.CR3").is_err()); + assert_eq!(fs.listing().len(), 4); + assert!(fs.listing().contains(&("/p/A.xmp".into(), "shared".into())) && !fs.exists(Path::new("/p/B.xmp"))); +} + +/// Issue #95 on the real disk, whichever kind of volume the temp folder is on. +#[test] +fn case_only_rename_on_disk() { + let dir = std::env::temp_dir().join(format!("lc-rename-case-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (upper, lower) = (dir.join("IMG_1.JPG"), dir.join("img_1.JPG")); + std::fs::write(&upper, b"first").unwrap(); + let case_sensitive = !lower.exists(); + let p = |q: &Path| q.to_string_lossy().to_string(); + if case_sensitive { + std::fs::write(&lower, b"second").unwrap(); + assert!(move_file(&p(&upper), &p(&lower)).is_err()); + assert_eq!(std::fs::read(&lower).unwrap(), b"second"); + assert_eq!(std::fs::read(&upper).unwrap(), b"first"); + } else { + move_file(&p(&upper), &p(&lower)).unwrap(); + let names: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(names, vec!["img_1.JPG".to_string()]); + assert_eq!(std::fs::read(&lower).unwrap(), b"first"); + } + // a plain rename never replaces + std::fs::write(dir.join("other.JPG"), b"other").unwrap(); + assert!(move_file(&p(&dir.join("other.JPG")), &p(&lower)).is_err()); + assert_eq!(std::fs::read(dir.join("other.JPG")).unwrap(), b"other"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn folder_templates_stay_inside_the_destination() { + let mut p = sample_photo(); + assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]); + assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too"); + assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]); + assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]); + // metadata can't add levels or climb out + p.meta.camera = "../../etc/x".into(); + p.meta.title = "..".into(); + assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]); + p.meta.camera = "C:\\Windows".into(); + assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]); + // missing metadata → `unknown`; missing capture time → the import time + p.meta.camera.clear(); + p.captured = None; + assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]); + for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] { + assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected"); + } + for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] { + assert_eq!(folder_template_error(ok), None, "{ok:?}"); + } + // even unchecked, rejected levels are skipped rather than followed + assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]); +} + +/// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is +/// replaced, never left literal, and the help's examples match the documented behaviour. +#[test] +fn token_help_matches_the_implementation() { + let p = sample_photo(); + for t in TOKENS { + for tag in std::iter::once(&t.tag).chain(t.aliases) { + let v = expand_tokens(tag, &p, 1, 1); + assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})"); + assert!(!v.is_empty(), "{tag}: the sample photo should give an example"); + } + } + assert_eq!(token_example("{seq:3}"), "001"); + assert_eq!(token_example("{date}"), "20260114"); + assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848"); + assert_eq!(token_example("{ext}"), "CR3"); + assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3"); + for (d, _) in DATE_DIRECTIVES { + let v = format_date("2026-01-14T05:58:48", d); + assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); + } + assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep"); + assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026"); + // a month that isn't 01–12 gives no name instead of panicking + for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] { + assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}"); + } + assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); + assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); + let json = token_help_json(); + assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len()); + assert_eq!(json["tokens"][0]["example"], "IMG_0042"); + // the command descriptions that list the tokens list all of them + for id in ["photo.rename", "photo.renamePreview", "library.import"] { + let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); + for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) { + assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag); + } + } +} + +#[test] +fn templates() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00"); + p.captured = Some("2026-09-30T14:05:09".into()); + p.meta.camera = "Model X/2".into(); + p.meta.title = "Sunset: beach".into(); + assert_eq!(expand("{name}", &p, 1), "IMG_0042.CR2"); + assert_eq!(expand("Trip-{seq:3}", &p, 7), "Trip-007.CR2"); + assert_eq!(expand("{date}_{name}", &p, 1), "20260930_IMG_0042.CR2"); + assert_eq!(expand("{date:%Y-%m-%d %H.%M.%S}", &p, 1), "2026-09-30 14.05.09.CR2"); + assert_eq!(expand("{camera} {title}", &p, 1), "Model X-2 Sunset- beach.CR2"); + assert_eq!(expand("{unknown}-{seq}", &p, 12), "{unknown}-12.CR2"); + assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name"); + assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components"); +} + +#[test] +fn number_folder_and_metadata_tokens() { + let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF"); + let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00"); + p.meta.lens = "24-70mm F2.8".into(); + p.meta.iso = Some(400); + p.meta.creator = "A. Person".into(); + p.rating = 4; + assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF"); + assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF"); + // no number at the end of the name / no folder: empty + p.file_name = "beach.NEF".into(); + p.source = Source::Demo { scene: 0 }; + assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF"); + // a bare {seq} is padded to the caller's width + assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07"); +} diff --git a/crates/engine/src/rename/tokens.rs b/crates/engine/src/rename/tokens.rs new file mode 100644 index 000000000..3490e272b --- /dev/null +++ b/crates/engine/src/rename/tokens.rs @@ -0,0 +1,271 @@ +use super::*; + +pub(super) fn split_ext(name: &str) -> (&str, &str) { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => (stem, ext), + _ => (name, ""), + } +} + +fn sanitize(s: &str) -> String { + let t: String = + s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect(); + t.trim().trim_matches('.').trim().to_string() +} + +/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name +/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month +/// that doesn't parse gives nothing. +pub(super) fn format_date(iso: &str, fmt: &str) -> String { + let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); + let month = iso.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); + let mut out = String::new(); + let mut it = fmt.chars(); + while let Some(c) = it.next() { + if c != '%' { + out.push(c); + continue; + } + match it.next() { + Some('Y') => out.push_str(&part(0, 4)), + Some('y') => out.push_str(&part(2, 4)), + Some('m') => out.push_str(&part(5, 7)), + Some('B') => out.push_str(month), + Some('b') => out.push_str(month.get(..3).unwrap_or(month)), + Some('d') => out.push_str(&part(8, 10)), + Some('H') => out.push_str(&part(11, 13)), + Some('M') => out.push_str(&part(14, 16)), + Some('S') => out.push_str(&part(17, 19)), + Some('%') => out.push('%'), + Some(o) => { + out.push('%'); + out.push(o); + } + None => out.push('%'), + } + } + out +} + +/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and +/// command description shows; a test checks that [`expand_tokens`] knows each of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct TokenHelp { + /// The token as typed, e.g. `{seq:3}`. + pub tag: &'static str, + /// Other spellings with the same meaning. + pub aliases: &'static [&'static str], + pub meaning: &'static str, +} + +/// Every template token, in the order the help shows them. +pub const TOKENS: &[TokenHelp] = &[ + TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, + TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, + TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, + TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, + TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, + TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, + TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, + TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, + TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, + TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, + TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, + TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, + TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, + TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, +]; + +/// The `%` directives of `{date:…}`. +pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ + ("%Y", "year, 4 digits"), + ("%y", "year, 2 digits"), + ("%m", "month 01–12"), + ("%B", "month name (January)"), + ("%b", "short month name (Jan)"), + ("%d", "day 01–31"), + ("%H", "hour 00–23"), + ("%M", "minute"), + ("%S", "second"), + ("%%", "a literal %"), +]; + +/// How templates behave, one sentence each (shown under the token list). +pub const TEMPLATE_NOTES: &[&str] = &[ + "The original extension is always added; {ext} only puts it inside the name as well.", + "A blank template keeps the original names.", + "{date} is the capture time; a photo without one uses the time it was imported.", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", + "Unknown tags stay as typed — check the preview for a {typo}.", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", +]; + +/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. +pub fn token_summary() -> String { + TOKENS.iter().map(|t| t.tag).collect::>().join(" ") +} + +/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). +pub fn sample_photo() -> Photo { + let mut p = + Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); + p.captured = Some("2026-01-14T05:58:48".into()); + p.rating = 4; + p.meta.camera = "Canon EOS R5".into(); + p.meta.lens = "RF24-70mm F2.8".into(); + p.meta.iso = Some(400); + p.meta.title = "Harbour".into(); + p.meta.creator = "Ann Lee".into(); + p +} + +/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. +pub fn token_example(tag: &str) -> String { + expand_tokens(tag, &sample_photo(), 1, 1) +} + +/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be +/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels. +pub fn folder_template_error(template: &str) -> Option { + let t = template.trim(); + if t.is_empty() { + return Some("the folder template is empty".into()); + } + let b = t.as_bytes(); + if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') { + return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into()); + } + if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) { + return Some("the folder template may not contain . or .. folders".into()); + } + None +} + +/// The folders (relative to the destination) a folder template gives `p`: the template's own `/` +/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder +/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and +/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty +/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s +/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped. +pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec { + template + .split(['/', '\\']) + .map(str::trim) + .filter(|s| !s.is_empty() && !matches!(*s, "." | "..")) + .map(|s| { + let v = sanitize(&expand_tokens(s, p, seq, 1)); + if v.is_empty() { "unknown".to_string() } else { v } + }) + .collect() +} + +/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. +pub fn unknown_tokens(template: &str) -> Vec { + let p = sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(j) = rest[i..].find('}') else { break }; + let tag = &rest[i..i + j + 1]; + if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = &rest[i + j + 1..]; + } + out +} + +/// Every token with its meaning and example, the date directives and the notes, as JSON (the +/// `photo.renameTokens` command). +pub fn token_help_json() -> serde_json::Value { + let tokens: Vec = TOKENS + .iter() + .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) + .collect(); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); + serde_json::json!({ + "tokens": tokens, + "dateDirectives": directives, + "notes": TEMPLATE_NOTES, + "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", + }) +} + +/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. +pub fn expand(template: &str, p: &Photo, seq: usize) -> String { + let (stem, ext) = split_ext(&p.file_name); + let out = expand_tokens(template, p, seq, 1); + let mut stem_out = sanitize(&out); + if stem_out.is_empty() { + stem_out = sanitize(stem); + } + if stem_out.is_empty() { + stem_out = "photo".into(); + } + if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") } +} + +/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` +/// is zero-padded to `seq_width` digits. +pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { + expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) +} + +/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text +/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename +/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. +pub fn expand_tokens_with( + template: &str, + p: &Photo, + seq: usize, + seq_width: usize, + extra: &dyn Fn(&str, Option<&str>, &str) -> Option, +) -> String { + let (stem, ext) = split_ext(&p.file_name); + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + out.push_str(&rest[..i]); + let Some(j) = rest[i..].find('}') else { + out.push_str(&rest[i..]); + rest = ""; + break; + }; + let tok = &rest[i + 1..i + j]; + let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); + let lower = name.trim().to_ascii_lowercase(); + let v = match extra(&lower, arg, tok) { + Some(v) => v, + None => match lower.as_str() { + "name" | "filename" => stem.to_string(), + "num" => { + let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); + stem[stem.len() - digits..].to_string() + } + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "folder" => match &p.source { + Source::File { path } => { + Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + } + Source::Demo { .. } => String::new(), + }, + "camera" => p.meta.camera.clone(), + "lens" => p.meta.lens.clone(), + "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), + "rating" => p.rating.to_string(), + "title" => p.meta.title.clone(), + "creator" => p.meta.creator.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), + }, + }; + out.push_str(&v); + rest = &rest[i + j + 1..]; + } + out.push_str(rest); + out +} diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment.rs similarity index 100% rename from crates/engine/src/segment/mod.rs rename to crates/engine/src/segment.rs diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs index a27f75d49..0d3d0c2b1 100644 --- a/crates/engine/src/slide_overlay.rs +++ b/crates/engine/src/slide_overlay.rs @@ -1026,319 +1026,4 @@ fn star(img: &mut Rgba8, x: f64, y: f64, size: f64, c: [u8; 3]) { } #[cfg(test)] -mod tests { - use super::*; - - fn layout(w: usize, h: usize) -> Layout { - lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) - } - - fn photo() -> Photo { - let mut p = crate::rename::sample_photo(); - p.rating = 3; - p.meta.caption = "Morning fog".into(); - p.meta.shutter = "1/250".into(); - p.meta.aperture = Some(8.0); - p.meta.focal_mm = Some(50.0); - p - } - - #[test] - fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { - let p = photo(); - assert_eq!(expand("{caption}", &p), "Morning fog"); - assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); - assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); - assert_eq!(expand("{file}", &p), p.file_name); - assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); - assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); - assert_eq!(expand("{rating}", &p), "3"); - // unknown and slide-less tags show as typed, and are reported - assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); - assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); - // a photo without metadata: empty values, no panic - let mut bare = p.clone(); - bare.meta = Default::default(); - assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); - } - - #[test] - fn nine_anchors_place_inside_the_slide_with_their_offsets() { - let l = layout(1000, 600); - let short = 600.0; - for anchor in ANCHORS { - for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { - let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); - let b = at(0.0, 0.0); - assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); - assert_eq!((b.width(), b.height()), (100.0, 40.0)); - let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; - let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); - let id = anchor_id(anchor); - let want_x = if id.ends_with("eft") { - c.x0 + 50.0 - } else if id.ends_with("ight") { - c.x1 - 50.0 - } else { - (c.x0 + c.x1) / 2.0 - }; - let want_y = if id.starts_with("top") { - c.y0 + 20.0 - } else if id.starts_with("bottom") { - c.y1 - 20.0 - } else { - (c.y0 + c.y1) / 2.0 - }; - assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); - // an inward offset moves it by that share of the shorter side - let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); - let m = at(sx, sy); - assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); - } - } - // pushed off the slide: kept on it - let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); - assert_eq!((b.x1, b.y1), (1000.0, 600.0)); - } - - fn blank(w: usize, h: usize) -> Rgba8 { - Image::filled(w, h, [0, 0, 0, 255]) - } - - fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { - let (x0, y0, x1, y1) = pixels(r, img.width, img.height); - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() - } - - #[test] - fn rating_stars_count_matches_the_rating() { - let l = layout(800, 500); - let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); - let mut p = photo(); - let mut widths = Vec::new(); - for rating in 0..=5u8 { - p.rating = rating; - let o = SlideOverlays::resolve(&s, Some(&p)); - let boxes = o.boxes(&l); - let mut img = blank(800, 500); - o.draw_front(&mut img, &l); - let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { - assert_eq!(rating, 0, "no stars only for an unrated photo"); - assert_eq!(lit(&img, &l.slide, 1), 0); - continue; - }; - widths.push(b.width()); - // count the stars: separate runs of lit columns along the box's middle row - let y = ((b.y0 + b.y1) / 2.0) as usize; - let mut runs = 0; - let mut was = false; - for x in 0..800 { - let on = img.get(x, y)[1] > 128; - runs += usize::from(on && !was); - was = on; - } - assert_eq!(runs, usize::from(rating), "{rating} stars"); - } - assert!(widths.windows(2).all(|w| w[1] > w[0])); - } - - #[test] - fn text_with_a_shadow_draws_the_shadow_at_its_angle() { - let l = layout(800, 500); - let s = OverlaySettings::default() - .merged( - &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, - "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), - ) - .unwrap(); - let o = SlideOverlays::resolve(&s, Some(&photo())); - let b = o.boxes(&l)[0].1; - let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); - o.draw_front(&mut grey, &l); - // straight down by 10 px: dark pixels below the text box's glyphs, none above - let dark = |r: Rect| { - let (x0, y0, x1, y1) = pixels(&r, 800, 500); - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() - }; - assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); - assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); - // without the shadow: no dark pixels at all - let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); - let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); - SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); - assert!(grey2.data.iter().all(|p| p[0] >= 128)); - } - - #[test] - fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { - let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("plate.png"); - let red = Image::filled(40, 20, [255u8, 0, 0, 255]); - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(&path, png).unwrap(); - let l = layout(800, 500); - let s = OverlaySettings::default() - .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) - .unwrap(); - assert!(s.warnings().is_empty()); - let o = SlideOverlays::resolve(&s, None); - let b = o.boxes(&l)[0].1; - assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); - let mut img = blank(800, 500); - o.draw_front(&mut img, &l); - let p = img.get(50, 25); - assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); - assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); - // behind the image: the photo covers it - let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); - let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); - let ob = SlideOverlays::resolve(&behind, None); - let (img, l2) = - lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { - ob.draw_behind(i, l) - }); - let _ = l2; - assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); - // a missing file: reported, nothing drawn, no panic - let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); - assert_eq!(gone.warnings().len(), 1); - let mut img = blank(800, 500); - SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); - assert!(img.data.iter().all(|p| p[0] == 0)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn overlays_are_resolution_independent() { - let s = OverlaySettings::default() - .merged(&json!({ - "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, - "ratingStars": {"enabled": true, "size": 0.04}, - "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], - })) - .unwrap(); - let o = SlideOverlays::resolve(&s, Some(&photo())); - let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); - assert_eq!(a.len(), 3); - for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { - assert_eq!(ka, kb); - for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { - assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); - } - } - } - - #[test] - fn watermark_takes_the_export_insets_and_rotation() { - let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap(); - assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90)); - let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap(); - assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2)); - assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err()); - } - - #[test] - fn hostile_settings_are_refused_or_clamped_and_never_panic() { - let d = OverlaySettings::default(); - for bad in [ - json!({"identityPlate": {"size": "big"}}), - json!({"identityPlate": {"anchor": "middle"}}), - json!({"identityPlate": 3}), - json!({"ratingStars": {"color": "#zzzzzz"}}), - json!({"texts": {"text": "a"}}), - json!({"texts": [{"relativeTo": "page"}]}), - json!({"texts": [{"sparkle": 1}]}), - json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), - json!({"removeText": 0}), - json!({"removeText": -1}), - json!({"watermark": {"size": 9}}), - json!({"watermark": 5}), - json!({"showWatermark": "yes"}), - json!({"glitter": true}), - json!([1]), - ] { - assert!(d.merged(&bad).is_err(), "{bad}"); - } - let s = d - .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) - .unwrap(); - assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); - assert_eq!(s.rating_stars.size, SIZE_MIN); - assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); - assert!(d.merged(&json!({"removeText": 0})).is_err()); - assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); - // round trips (command JSON and prefs.json) - assert_eq!(d.merged(&s.to_json()).unwrap(), s); - let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); - assert_eq!(back, s); - // NaN / ∞ from a hand-edited prefs file - let mut wild = s.clone(); - wild.identity_plate.size = f64::NAN; - wild.texts[0].place.offset_y = f64::INFINITY; - wild.watermark.size = f32::NAN; - let w = wild.sanitized(); - assert_eq!( - (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), - (IdentityPlate::default().size, 0.0, Watermark::default().size) - ); - // drawing hostile text at every size: empty, huge, CJK, RTL, control characters - let huge = "W".repeat(50_000); - for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] - { - let s = d - .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) - .unwrap(); - assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); - let mut bare = photo(); - bare.meta = Default::default(); - for (w, h) in [(1, 1), (17, 9), (320, 200)] { - let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); - let o = SlideOverlays::resolve(&s, Some(&bare)); - let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); - o.draw_behind(&mut img, &l); - o.draw_front(&mut img, &l); - for (_, b) in o.boxes(&l) { - assert!(b.x0.is_finite() && b.y1.is_finite()); - } - } - } - } - - /// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. - #[test] - fn huge_overlays_on_a_big_slide_stay_cheap() { - let long = "Wide ".repeat(400); - let s = OverlaySettings::default() - .merged(&json!({ - "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, - "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, - })) - .unwrap(); - let mut p = photo(); - p.rating = 5; - let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); - let mut img = blank(4000, 2500); - let t = std::time::Instant::now(); - SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); - assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); - assert!(img.get(100, 300)[0] > 0, "the text's start shows"); - } - - /// CJK text draws with the craft-fonts faces (skips without them). - #[test] - fn cjk_text_uses_the_craft_fonts() { - if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { - eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); - return; - } - let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); - let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); - let count = |t: &crate::text::TextLayout| { - let mut n = 0usize; - t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); - n - }; - assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); - } -} +mod tests; diff --git a/crates/engine/src/slide_overlay/tests.rs b/crates/engine/src/slide_overlay/tests.rs new file mode 100644 index 000000000..aa21d3703 --- /dev/null +++ b/crates/engine/src/slide_overlay/tests.rs @@ -0,0 +1,307 @@ +use super::*; + +fn layout(w: usize, h: usize) -> Layout { + lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) +} + +fn photo() -> Photo { + let mut p = crate::rename::sample_photo(); + p.rating = 3; + p.meta.caption = "Morning fog".into(); + p.meta.shutter = "1/250".into(); + p.meta.aperture = Some(8.0); + p.meta.focal_mm = Some(50.0); + p +} + +#[test] +fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { + let p = photo(); + assert_eq!(expand("{caption}", &p), "Morning fog"); + assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); + assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); + assert_eq!(expand("{file}", &p), p.file_name); + assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); + assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); + assert_eq!(expand("{rating}", &p), "3"); + // unknown and slide-less tags show as typed, and are reported + assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); + assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); + // a photo without metadata: empty values, no panic + let mut bare = p.clone(); + bare.meta = Default::default(); + assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); +} + +#[test] +fn nine_anchors_place_inside_the_slide_with_their_offsets() { + let l = layout(1000, 600); + let short = 600.0; + for anchor in ANCHORS { + for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { + let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); + let b = at(0.0, 0.0); + assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); + assert_eq!((b.width(), b.height()), (100.0, 40.0)); + let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; + let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + let id = anchor_id(anchor); + let want_x = if id.ends_with("eft") { + c.x0 + 50.0 + } else if id.ends_with("ight") { + c.x1 - 50.0 + } else { + (c.x0 + c.x1) / 2.0 + }; + let want_y = if id.starts_with("top") { + c.y0 + 20.0 + } else if id.starts_with("bottom") { + c.y1 - 20.0 + } else { + (c.y0 + c.y1) / 2.0 + }; + assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); + // an inward offset moves it by that share of the shorter side + let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); + let m = at(sx, sy); + assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); + } + } + // pushed off the slide: kept on it + let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); + assert_eq!((b.x1, b.y1), (1000.0, 600.0)); +} + +fn blank(w: usize, h: usize) -> Rgba8 { + Image::filled(w, h, [0, 0, 0, 255]) +} + +fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { + let (x0, y0, x1, y1) = pixels(r, img.width, img.height); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() +} + +#[test] +fn rating_stars_count_matches_the_rating() { + let l = layout(800, 500); + let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); + let mut p = photo(); + let mut widths = Vec::new(); + for rating in 0..=5u8 { + p.rating = rating; + let o = SlideOverlays::resolve(&s, Some(&p)); + let boxes = o.boxes(&l); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { + assert_eq!(rating, 0, "no stars only for an unrated photo"); + assert_eq!(lit(&img, &l.slide, 1), 0); + continue; + }; + widths.push(b.width()); + // count the stars: separate runs of lit columns along the box's middle row + let y = ((b.y0 + b.y1) / 2.0) as usize; + let mut runs = 0; + let mut was = false; + for x in 0..800 { + let on = img.get(x, y)[1] > 128; + runs += usize::from(on && !was); + was = on; + } + assert_eq!(runs, usize::from(rating), "{rating} stars"); + } + assert!(widths.windows(2).all(|w| w[1] > w[0])); +} + +#[test] +fn text_with_a_shadow_draws_the_shadow_at_its_angle() { + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged(&json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, + "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]})) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let b = o.boxes(&l)[0].1; + let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); + o.draw_front(&mut grey, &l); + // straight down by 10 px: dark pixels below the text box's glyphs, none above + let dark = |r: Rect| { + let (x0, y0, x1, y1) = pixels(&r, 800, 500); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() + }; + assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); + assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); + // without the shadow: no dark pixels at all + let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); + let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); + SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); + assert!(grey2.data.iter().all(|p| p[0] >= 128)); +} + +#[test] +fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { + let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plate.png"); + let red = Image::filled(40, 20, [255u8, 0, 0, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(&path, png).unwrap(); + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) + .unwrap(); + assert!(s.warnings().is_empty()); + let o = SlideOverlays::resolve(&s, None); + let b = o.boxes(&l)[0].1; + assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let p = img.get(50, 25); + assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); + assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); + // behind the image: the photo covers it + let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); + let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); + let ob = SlideOverlays::resolve(&behind, None); + let (img, l2) = lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { + ob.draw_behind(i, l) + }); + let _ = l2; + assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); + // a missing file: reported, nothing drawn, no panic + let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); + assert_eq!(gone.warnings().len(), 1); + let mut img = blank(800, 500); + SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); + assert!(img.data.iter().all(|p| p[0] == 0)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn overlays_are_resolution_independent() { + let s = OverlaySettings::default() + .merged(&json!({ + "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, + "ratingStars": {"enabled": true, "size": 0.04}, + "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], + })) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); + assert_eq!(a.len(), 3); + for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { + assert_eq!(ka, kb); + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); + } + } +} + +#[test] +fn watermark_takes_the_export_insets_and_rotation() { + let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90)); + let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2)); + assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err()); +} + +#[test] +fn hostile_settings_are_refused_or_clamped_and_never_panic() { + let d = OverlaySettings::default(); + for bad in [ + json!({"identityPlate": {"size": "big"}}), + json!({"identityPlate": {"anchor": "middle"}}), + json!({"identityPlate": 3}), + json!({"ratingStars": {"color": "#zzzzzz"}}), + json!({"texts": {"text": "a"}}), + json!({"texts": [{"relativeTo": "page"}]}), + json!({"texts": [{"sparkle": 1}]}), + json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), + json!({"removeText": 0}), + json!({"removeText": -1}), + json!({"watermark": {"size": 9}}), + json!({"watermark": 5}), + json!({"showWatermark": "yes"}), + json!({"glitter": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) + .unwrap(); + assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); + assert_eq!(s.rating_stars.size, SIZE_MIN); + assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); + assert!(d.merged(&json!({"removeText": 0})).is_err()); + assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); + // round trips (command JSON and prefs.json) + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + // NaN / ∞ from a hand-edited prefs file + let mut wild = s.clone(); + wild.identity_plate.size = f64::NAN; + wild.texts[0].place.offset_y = f64::INFINITY; + wild.watermark.size = f32::NAN; + let w = wild.sanitized(); + assert_eq!((w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), (IdentityPlate::default().size, 0.0, Watermark::default().size)); + // drawing hostile text at every size: empty, huge, CJK, RTL, control characters + let huge = "W".repeat(50_000); + for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] { + let s = d + .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) + .unwrap(); + assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); + let mut bare = photo(); + bare.meta = Default::default(); + for (w, h) in [(1, 1), (17, 9), (320, 200)] { + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); + let o = SlideOverlays::resolve(&s, Some(&bare)); + let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); + o.draw_behind(&mut img, &l); + o.draw_front(&mut img, &l); + for (_, b) in o.boxes(&l) { + assert!(b.x0.is_finite() && b.y1.is_finite()); + } + } + } +} + +/// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. +#[test] +fn huge_overlays_on_a_big_slide_stay_cheap() { + let long = "Wide ".repeat(400); + let s = OverlaySettings::default() + .merged(&json!({ + "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, + "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, + })) + .unwrap(); + let mut p = photo(); + p.rating = 5; + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); + let mut img = blank(4000, 2500); + let t = std::time::Instant::now(); + SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); + assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); + assert!(img.get(100, 300)[0] > 0, "the text's start shows"); +} + +/// CJK text draws with the craft-fonts faces (skips without them). +#[test] +fn cjk_text_uses_the_craft_fonts() { + if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { + eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); + return; + } + let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); + let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); + let count = |t: &crate::text::TextLayout| { + let mut n = 0usize; + t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); + n + }; + assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); +} diff --git a/crates/engine/src/slide_template.rs b/crates/engine/src/slide_template.rs index 32e26ed59..bf56a6569 100644 --- a/crates/engine/src/slide_template.rs +++ b/crates/engine/src/slide_template.rs @@ -628,5 +628,4 @@ impl Session { } #[cfg(test)] -#[path = "tests_slide_template.rs"] mod tests; diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/slide_template/tests.rs similarity index 100% rename from crates/engine/src/tests_slide_template.rs rename to crates/engine/src/slide_template/tests.rs diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs index dbee505af..0e1c1294d 100644 --- a/crates/engine/src/slideshow.rs +++ b/crates/engine/src/slideshow.rs @@ -1,6 +1,7 @@ //! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that -//! plays the photos in view (durations, fades, random order, repeat, pause, stepping), between an -//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]). +//! plays the photos in view (durations, fades, random order, repeat, pause, stepping, manual +//! advance), between an optional intro and ending screen (the Titles panel, +//! [`crate::slide_title`]). //! //! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any //! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline. @@ -39,11 +40,22 @@ pub struct PlaybackSettings { pub repeat: bool, /// Render the next slides before they are due. pub prepare_previews: bool, + /// Manual slideshow: a slide stays up until ← / → (or the controls) change it; the slide + /// duration is not used for playback then. + pub manual: bool, } impl Default for PlaybackSettings { fn default() -> Self { - PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true } + PlaybackSettings { + slide_duration: 4.0, + fade_duration: 1.0, + fade_color: None, + random_order: false, + repeat: true, + prepare_previews: true, + manual: false, + } } } @@ -66,6 +78,7 @@ impl PlaybackSettings { "randomOrder": self.random_order, "repeat": self.repeat, "preparePreviews": self.prepare_previews, + "manual": self.manual, }) } @@ -82,8 +95,11 @@ impl PlaybackSettings { "randomOrder" => s.random_order = boolean(k, v)?, "repeat" => s.repeat = boolean(k, v)?, "preparePreviews" => s.prepare_previews = boolean(k, v)?, + "manual" => s.manual = boolean(k, v)?, other => { - return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)")); + return Err(format!( + "unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual)" + )); } } } @@ -432,8 +448,12 @@ impl Playback { self.started + self.duration(self.current()) } - /// Seconds until the next slide is due (as of `now`; frozen while paused). + /// Seconds until the next slide is due (as of `now`; frozen while paused; infinite in a + /// manual slideshow, where no slide is ever due). pub fn remaining(&self, now: f64) -> f64 { + if self.settings.manual { + return f64::INFINITY; + } (self.due() - self.clock(now)).max(0.0) } @@ -490,7 +510,8 @@ impl Playback { self.started = now; return Tick::Idle; } - if now < self.due() { + // a manual slideshow moves only with `step` (← / →) + if self.settings.manual || now < self.due() { return Tick::Idle; } let Some(next) = self.upcoming(1).first().copied() else { @@ -586,336 +607,4 @@ impl Playback { } #[cfg(test)] -mod tests { - use super::*; - - fn ids(n: u64) -> Vec { - (1..=n).map(PhotoId).collect() - } - - fn settings(slide: f64, fade: f64) -> PlaybackSettings { - PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } - } - - fn ph(n: u64) -> Item { - Item::Photo(PhotoId(n)) - } - - fn all_ready(_: Item) -> bool { - true - } - - #[test] - fn timeline_durations_and_fade_progress() { - let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); - assert_eq!(p.current(), ph(2), "starts at the selected photo"); - assert_eq!(p.position(), (1, 3)); - assert_eq!(p.tick(13.9, all_ready), Tick::Idle); - assert_eq!(p.frame(13.9).from, None); - assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3))); - let f = p.frame(14.25); - assert_eq!((f.current, f.from), (ph(3), Some(ph(2)))); - assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); - assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); - assert_eq!(p.tick(15.0, all_ready), Tick::Idle); - assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); - // the slide period counts from its transition: next at 18 - assert_eq!(p.tick(17.99, all_ready), Tick::Idle); - assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(ph(1)), "wraps to the photos before the start"); - assert_eq!(p.position(), (3, 3)); - } - - #[test] - fn fade_zero_is_a_cut_and_fade_color_is_reported() { - let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.frame(2.0).from, None); - let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; - let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); - p.tick(2.0, all_ready); - assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0])); - } - - #[test] - fn arrows_cut_immediately_and_restart_the_slide_time() { - let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); - p.tick(4.0, all_ready); - assert!(p.frame(4.5).from.is_some(), "fading"); - assert_eq!(p.step(1, 4.5), Tick::Advanced(ph(3))); - assert_eq!(p.frame(4.5), Frame { current: ph(3), from: None, progress: 1.0, fade_color: None }, "a cut"); - assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); - assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(2))); - assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1))); - // repeat on: before the first is the last - assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4))); - assert_eq!(p.remaining(6.0), 4.0); - } - - #[test] - fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() { - let photos = ids(20); - let run = |seed: u64| { - let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; - let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); - let mut seen = vec![p.current_photo().unwrap()]; - for k in 1..60 { - p.tick(k as f64, all_ready); - seen.push(p.current_photo().unwrap()); - } - seen - }; - let a = run(42); - assert_eq!(a, run(42), "same seed, same order"); - assert_ne!(a, run(43), "another seed, another order"); - assert_eq!(a[0], PhotoId(5), "starts at the selected photo"); - assert_ne!(a[..20].to_vec(), photos, "shuffled"); - for pass in a.chunks(20).take(2) { - let mut sorted = pass.to_vec(); - sorted.sort(); - assert_eq!(sorted, photos, "every photo once per pass"); - } - assert_ne!(a[..20], a[20..40], "a new order for the next pass"); - assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row"); - } - - #[test] - fn repeat_off_stops_after_the_last_slide() { - let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; - let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); - assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(3))); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1))); - assert_eq!(p.upcoming(2), vec![], "nothing after the last"); - assert_eq!(p.tick(3.0, all_ready), Tick::Ended); - assert!(p.is_ended()); - // → past the last one ends it too - let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); - assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(2))); - assert_eq!(p.step(1, 0.6), Tick::Ended); - // with repeat: it goes on - let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); - for k in 1..10 { - assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_))); - } - } - - #[test] - fn pause_and_resume_keep_the_remaining_time() { - let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap(); - assert!(p.toggle_pause(3.0)); - assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves"); - assert_eq!(p.remaining(100.0), 1.0); - assert!(!p.toggle_pause(100.0)); - assert_eq!(p.tick(100.9, all_ready), Tick::Idle); - assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(ph(2)), "1 s left after resuming"); - // pausing mid-fade freezes the fade - p.toggle_pause(101.25); - assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); - p.toggle_pause(150.0); - assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6); - } - - #[test] - fn a_transition_waits_for_the_next_slide_to_be_ready() { - let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); - assert_eq!(p.upcoming(2), vec![ph(2), ph(3)], "what to prepare"); - let not_two = |id: Item| id != ph(2); - assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2))); - assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); - assert_eq!(p.waiting_for(), Some(ph(2))); - assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.frame(3.5).from, Some(ph(1)), "the fade starts once it is ready"); - assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); - } - - #[test] - fn a_slide_s_time_starts_once_it_is_ready() { - let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); - let none = |_: Item| false; - assert_eq!(p.tick(1.0, none), Tick::Idle); - assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); - assert_eq!(p.remaining(5.0), 2.0); - assert_eq!(p.tick(6.0, all_ready), Tick::Idle); - assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown"); - } - - #[test] - fn hostile_settings_are_rejected_or_clamped() { - let d = PlaybackSettings::default(); - for bad in [ - json!({"slideDuration": "fast"}), - json!({"fadeDuration": null}), - json!({"repeat": 1}), - json!({"fadeColor": "#12345"}), - json!({"fadeColor": "#ééé"}), - json!({"fadeColor": "#+f+f+f"}), - json!({"fadeColor": "-12345"}), - json!({"fadeColor": [300, 0, 0]}), - json!({"fadeColor": [1, 2]}), - json!({"speed": 2}), - json!([1, 2]), - ] { - assert!(d.merged(&bad).is_err(), "{bad}"); - } - let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap(); - assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0)); - let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap(); - assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide"); - let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap(); - assert_eq!(s.fade_color, Some([255, 128, 0])); - assert_eq!(s.to_json()["fadeColor"], "#ff8000"); - assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3])); - // NaN / inf (from a hand-edited file or a caller in-process) take the defaults - let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized(); - assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration)); - // and the timeline never panics on hostile times - let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap(); - p.tick(f64::NAN, all_ready); - p.tick(f64::INFINITY, all_ready); - p.toggle_pause(f64::NEG_INFINITY); - p.step(i32::MIN, f64::NAN); - let _ = p.frame(f64::NAN); - assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); - } - - #[test] - fn a_single_photo_repeats_without_changing() { - let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); - assert_eq!(p.upcoming(2), vec![]); - assert_eq!(p.tick(5.0, all_ready), Tick::Idle); - assert_eq!(p.frame(5.0).from, None); - } - - fn titled(n: u64, s: PlaybackSettings, intro: Option, ending: Option) -> Playback { - Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending) - } - - #[test] - fn titles_play_once_around_the_photos_for_their_own_durations() { - let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; - let mut p = titled(3, s, Some(2.0), Some(3.0)); - assert_eq!(p.current(), Item::INTRO, "the intro comes first"); - assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos"); - assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]); - assert_eq!(p.tick(1.99, all_ready), Tick::Idle); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s"); - let f = p.frame(2.5); - assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide"); - assert!((f.progress - 0.5).abs() < 1e-6); - assert_eq!(p.position(), (1, 3)); - assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3))); - assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide"); - assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING)); - assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending))); - assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in"); - assert_eq!(p.tick(16.99, all_ready), Tick::Idle); - assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends"); - // without titles nothing changes - let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None); - assert_eq!(p.current(), ph(1)); - assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.tick(2.0, all_ready), Tick::Ended); - } - - #[test] - fn a_fade_never_outlasts_a_short_title() { - let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) }; - let mut p = titled(1, s, Some(0.5), Some(0.5)); - assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1))); - assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s"); - assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING)); - assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade"); - assert_eq!(p.frame(5.0).from, None); - assert_eq!(p.tick(5.0, all_ready), Tick::Ended); - } - - #[test] - fn with_repeat_the_intro_plays_once_and_the_ending_never() { - let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0)); - let mut seen = vec![p.current()]; - for k in 1..12 { - p.tick(k as f64, all_ready); - seen.push(p.current()); - assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating"); - } - assert_eq!(seen[0], Item::INTRO); - assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}"); - assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]); - } - - #[test] - fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() { - for seed in [1, 2, 3] { - let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) }; - let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0)); - let mut seen = vec![p.current()]; - let mut k = 1.0; - while p.tick(k, all_ready) != Tick::Ended { - seen.push(p.current()); - k += 1.0; - } - assert_eq!(seen.len(), 12, "{seen:?}"); - assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}"); - let mut photos: Vec = seen[1..11].iter().filter_map(|i| i.photo()).collect(); - assert_ne!(photos, ids(10), "shuffled"); - photos.sort(); - assert_eq!(photos, ids(10), "every photo once, between the titles"); - } - } - - #[test] - fn arrows_step_across_the_titles() { - let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; - let mut p = titled(3, s, Some(2.0), Some(2.0)); - assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there"); - assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1))); - assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro"); - assert_eq!(p.remaining(0.6), 2.0, "with its full time"); - assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3))); - assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); - assert_eq!(p.position(), (3, 3)); - assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo"); - assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); - assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show"); - assert_eq!(p.step(1, 2.0), Tick::Ended); - // repeating: ← from the first photo of a later pass wraps to the last, not the intro - let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None); - assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again"); - assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3))); - assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current())); - assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current())); - } - - #[test] - fn a_title_pauses_and_waits_like_a_slide() { - let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None); - let not_intro = |i: Item| i != Item::INTRO; - assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits"); - assert_eq!(p.remaining(5.0), 3.0); - assert!(p.toggle_pause(6.0)); - assert_eq!(p.tick(100.0, all_ready), Tick::Idle); - assert_eq!(p.remaining(100.0), 2.0, "paused on the intro"); - assert!(!p.toggle_pause(100.0)); - assert_eq!(p.tick(101.9, all_ready), Tick::Idle); - let not_one = |i: Item| i != ph(1); - assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held"); - assert_eq!(p.frame(102.5).current, Item::INTRO); - assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1))); - } - - #[test] - fn hostile_title_durations_are_clamped() { - let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300)); - assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX)); - let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY)); - assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0)); - // a single photo between titles, repeating: the photo holds after the intro - let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0)); - assert_eq!(p.upcoming(3), vec![ph(1)]); - assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1))); - assert_eq!(p.upcoming(3), vec![]); - assert_eq!(p.tick(9.0, all_ready), Tick::Idle); - assert_eq!(Title::parse(" Ending "), Some(Title::Ending)); - assert_eq!(Title::parse("credits"), None); - } -} +mod tests; diff --git a/crates/engine/src/slideshow/tests.rs b/crates/engine/src/slideshow/tests.rs new file mode 100644 index 000000000..d329d5993 --- /dev/null +++ b/crates/engine/src/slideshow/tests.rs @@ -0,0 +1,355 @@ +use super::*; + +fn ids(n: u64) -> Vec { + (1..=n).map(PhotoId).collect() +} + +fn settings(slide: f64, fade: f64) -> PlaybackSettings { + PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } +} + +fn ph(n: u64) -> Item { + Item::Photo(PhotoId(n)) +} + +fn all_ready(_: Item) -> bool { + true +} + +#[test] +fn timeline_durations_and_fade_progress() { + let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); + assert_eq!(p.current(), ph(2), "starts at the selected photo"); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(13.9, all_ready), Tick::Idle); + assert_eq!(p.frame(13.9).from, None); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3))); + let f = p.frame(14.25); + assert_eq!((f.current, f.from), (ph(3), Some(ph(2)))); + assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); + assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); + assert_eq!(p.tick(15.0, all_ready), Tick::Idle); + assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); + // the slide period counts from its transition: next at 18 + assert_eq!(p.tick(17.99, all_ready), Tick::Idle); + assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(ph(1)), "wraps to the photos before the start"); + assert_eq!(p.position(), (3, 3)); +} + +#[test] +fn fade_zero_is_a_cut_and_fade_color_is_reported() { + let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.frame(2.0).from, None); + let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + p.tick(2.0, all_ready); + assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0])); +} + +#[test] +fn arrows_cut_immediately_and_restart_the_slide_time() { + let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); + p.tick(4.0, all_ready); + assert!(p.frame(4.5).from.is_some(), "fading"); + assert_eq!(p.step(1, 4.5), Tick::Advanced(ph(3))); + assert_eq!(p.frame(4.5), Frame { current: ph(3), from: None, progress: 1.0, fade_color: None }, "a cut"); + assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(2))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1))); + // repeat on: before the first is the last + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4))); + assert_eq!(p.remaining(6.0), 4.0); +} + +#[test] +fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() { + let photos = ids(20); + let run = |seed: u64| { + let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; + let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); + let mut seen = vec![p.current_photo().unwrap()]; + for k in 1..60 { + p.tick(k as f64, all_ready); + seen.push(p.current_photo().unwrap()); + } + seen + }; + let a = run(42); + assert_eq!(a, run(42), "same seed, same order"); + assert_ne!(a, run(43), "another seed, another order"); + assert_eq!(a[0], PhotoId(5), "starts at the selected photo"); + assert_ne!(a[..20].to_vec(), photos, "shuffled"); + for pass in a.chunks(20).take(2) { + let mut sorted = pass.to_vec(); + sorted.sort(); + assert_eq!(sorted, photos, "every photo once per pass"); + } + assert_ne!(a[..20], a[20..40], "a new order for the next pass"); + assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row"); +} + +#[test] +fn repeat_off_stops_after_the_last_slide() { + let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1))); + assert_eq!(p.upcoming(2), vec![], "nothing after the last"); + assert_eq!(p.tick(3.0, all_ready), Tick::Ended); + assert!(p.is_ended()); + // → past the last one ends it too + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(2))); + assert_eq!(p.step(1, 0.6), Tick::Ended); + // with repeat: it goes on + let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); + for k in 1..10 { + assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_))); + } +} + +#[test] +fn pause_and_resume_keep_the_remaining_time() { + let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap(); + assert!(p.toggle_pause(3.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves"); + assert_eq!(p.remaining(100.0), 1.0); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(100.9, all_ready), Tick::Idle); + assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(ph(2)), "1 s left after resuming"); + // pausing mid-fade freezes the fade + p.toggle_pause(101.25); + assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); + p.toggle_pause(150.0); + assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6); +} + +#[test] +fn a_transition_waits_for_the_next_slide_to_be_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![ph(2), ph(3)], "what to prepare"); + let not_two = |id: Item| id != ph(2); + assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2))); + assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); + assert_eq!(p.waiting_for(), Some(ph(2))); + assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.frame(3.5).from, Some(ph(1)), "the fade starts once it is ready"); + assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); +} + +#[test] +fn a_slide_s_time_starts_once_it_is_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); + let none = |_: Item| false; + assert_eq!(p.tick(1.0, none), Tick::Idle); + assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 2.0); + assert_eq!(p.tick(6.0, all_ready), Tick::Idle); + assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown"); +} + +#[test] +fn hostile_settings_are_rejected_or_clamped() { + let d = PlaybackSettings::default(); + for bad in [ + json!({"slideDuration": "fast"}), + json!({"fadeDuration": null}), + json!({"repeat": 1}), + json!({"fadeColor": "#12345"}), + json!({"fadeColor": "#ééé"}), + json!({"fadeColor": "#+f+f+f"}), + json!({"fadeColor": "-12345"}), + json!({"fadeColor": [300, 0, 0]}), + json!({"fadeColor": [1, 2]}), + json!({"speed": 2}), + json!([1, 2]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0)); + let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide"); + let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap(); + assert_eq!(s.fade_color, Some([255, 128, 0])); + assert_eq!(s.to_json()["fadeColor"], "#ff8000"); + assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3])); + // NaN / inf (from a hand-edited file or a caller in-process) take the defaults + let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized(); + assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration)); + // and the timeline never panics on hostile times + let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap(); + p.tick(f64::NAN, all_ready); + p.tick(f64::INFINITY, all_ready); + p.toggle_pause(f64::NEG_INFINITY); + p.step(i32::MIN, f64::NAN); + let _ = p.frame(f64::NAN); + assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); +} + +#[test] +fn a_manual_slideshow_moves_only_with_the_arrows() { + let s = PlaybackSettings { manual: true, repeat: false, ..settings(1.0, 0.5) }; + let mut p = titled(3, s, Some(1.0), Some(1.0)); + assert_eq!(p.tick(1000.0, all_ready), Tick::Idle, "the intro stays up past its duration"); + assert_eq!(p.current(), Item::INTRO); + assert_eq!(p.remaining(1000.0), f64::INFINITY, "nothing is ever due"); + assert_eq!(p.upcoming(1), vec![ph(1)], "the next slide is still prepared for →"); + assert_eq!(p.step(1, 1000.0), Tick::Advanced(ph(1))); + assert_eq!(p.tick(5000.0, all_ready), Tick::Idle); + assert_eq!(p.current(), ph(1), "a photo stays up too"); + assert_eq!(p.step(-1, 5000.0), Tick::Advanced(Item::INTRO), "← goes back"); + assert_eq!(p.step(4, 5000.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.tick(9000.0, all_ready), Tick::Idle, "the ending stays up until → ends the show"); + assert_eq!(p.step(1, 9000.0), Tick::Ended); + // the setting round-trips and is checked like the others + let d = PlaybackSettings::default(); + assert!(!d.manual, "off by default"); + let m = d.merged(&json!({"manual": true})).unwrap(); + assert!(m.manual); + assert_eq!(m.to_json()["manual"], true); + assert!(d.merged(&json!({"manual": "yes"})).is_err()); +} + +#[test] +fn a_single_photo_repeats_without_changing() { + let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![]); + assert_eq!(p.tick(5.0, all_ready), Tick::Idle); + assert_eq!(p.frame(5.0).from, None); +} + +fn titled(n: u64, s: PlaybackSettings, intro: Option, ending: Option) -> Playback { + Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending) +} + +#[test] +fn titles_play_once_around_the_photos_for_their_own_durations() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(3.0)); + assert_eq!(p.current(), Item::INTRO, "the intro comes first"); + assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos"); + assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]); + assert_eq!(p.tick(1.99, all_ready), Tick::Idle); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s"); + let f = p.frame(2.5); + assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide"); + assert!((f.progress - 0.5).abs() < 1e-6); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide"); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING)); + assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending))); + assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in"); + assert_eq!(p.tick(16.99, all_ready), Tick::Idle); + assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends"); + // without titles nothing changes + let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None); + assert_eq!(p.current(), ph(1)); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(2.0, all_ready), Tick::Ended); +} + +#[test] +fn a_fade_never_outlasts_a_short_title() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) }; + let mut p = titled(1, s, Some(0.5), Some(0.5)); + assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1))); + assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s"); + assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING)); + assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade"); + assert_eq!(p.frame(5.0).from, None); + assert_eq!(p.tick(5.0, all_ready), Tick::Ended); +} + +#[test] +fn with_repeat_the_intro_plays_once_and_the_ending_never() { + let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + for k in 1..12 { + p.tick(k as f64, all_ready); + seen.push(p.current()); + assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating"); + } + assert_eq!(seen[0], Item::INTRO); + assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}"); + assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]); +} + +#[test] +fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() { + for seed in [1, 2, 3] { + let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + let mut k = 1.0; + while p.tick(k, all_ready) != Tick::Ended { + seen.push(p.current()); + k += 1.0; + } + assert_eq!(seen.len(), 12, "{seen:?}"); + assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}"); + let mut photos: Vec = seen[1..11].iter().filter_map(|i| i.photo()).collect(); + assert_ne!(photos, ids(10), "shuffled"); + photos.sort(); + assert_eq!(photos, ids(10), "every photo once, between the titles"); + } +} + +#[test] +fn arrows_step_across_the_titles() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(2.0)); + assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there"); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1))); + assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro"); + assert_eq!(p.remaining(0.6), 2.0, "with its full time"); + assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.position(), (3, 3)); + assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo"); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show"); + assert_eq!(p.step(1, 2.0), Tick::Ended); + // repeating: ← from the first photo of a later pass wraps to the last, not the intro + let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None); + assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again"); + assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current())); + assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current())); +} + +#[test] +fn a_title_pauses_and_waits_like_a_slide() { + let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None); + let not_intro = |i: Item| i != Item::INTRO; + assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 3.0); + assert!(p.toggle_pause(6.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle); + assert_eq!(p.remaining(100.0), 2.0, "paused on the intro"); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(101.9, all_ready), Tick::Idle); + let not_one = |i: Item| i != ph(1); + assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held"); + assert_eq!(p.frame(102.5).current, Item::INTRO); + assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1))); +} + +#[test] +fn hostile_title_durations_are_clamped() { + let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX)); + let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0)); + // a single photo between titles, repeating: the photo holds after the intro + let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0)); + assert_eq!(p.upcoming(3), vec![ph(1)]); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1))); + assert_eq!(p.upcoming(3), vec![]); + assert_eq!(p.tick(9.0, all_ready), Tick::Idle); + assert_eq!(Title::parse(" Ending "), Some(Title::Ending)); + assert_eq!(Title::parse("credits"), None); +} diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 9f72e0f48..b936830f8 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -500,357 +500,4 @@ pub fn load(path: &Path) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn temp(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn writable_check_does_not_create_missing_parents() { - let base = temp("writable"); - assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created"); - assert!(base.join("new").is_dir()); - let err = check_writable(&base.join("gone/deeper")).unwrap_err(); - assert!(err.contains("not available"), "{err}"); - assert!(!base.join("gone").exists(), "no fallback onto the current drive"); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn migrate_moves_leaves_or_discards() { - let base = temp("migrate"); - let (a, b) = (base.join("a"), base.join("b")); - std::fs::create_dir_all(&a).unwrap(); - std::fs::create_dir_all(&b).unwrap(); - std::fs::write(a.join("1.lcsp"), b"one").unwrap(); - std::fs::write(a.join("note.txt"), b"keep").unwrap(); - assert_eq!(stats(&a), (1, 3)); - assert_eq!(migrate(&a, &b, Existing::Leave).0, 0); - assert_eq!(stats(&a).0, 1); - assert_eq!(migrate(&a, &b, Existing::Move).0, 1); - assert_eq!((stats(&a).0, stats(&b).0), (0, 1)); - assert!(a.join("note.txt").exists(), "only smart previews are touched"); - assert_eq!(migrate(&b, &a, Existing::Discard).0, 1); - assert_eq!(stats(&b).0, 0); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn sharded_and_legacy_proxies_are_counted_and_moved() { - let base = temp("shards"); - let from = base.join("from"); - let to = base.join("to"); - std::fs::create_dir_all(from.join("a1")).unwrap(); - std::fs::create_dir(&to).unwrap(); - let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp"; - let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp"; - std::fs::write(from.join(old), b"legacy").unwrap(); - std::fs::write(from.join("a1").join(new), b"sharded").unwrap(); - std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap(); - std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap(); - assert_eq!(stats(&from), (2, 13)); - let (moved, failed) = migrate(&from, &to, Existing::Move); - assert_eq!((moved, failed), (2, Vec::::new())); - assert_eq!(stats(&from), (0, 0)); - assert_eq!(stats(&to), (2, 13)); - assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy"); - assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded"); - assert!(from.join("a1").join("notes.txt").exists()); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { - let base = temp("valid"); - let (a, b) = (base.join("a"), base.join("b")); - std::fs::create_dir_all(&a).unwrap(); - std::fs::create_dir_all(&b).unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let good = encode(&img, None).unwrap(); - std::fs::write(a.join("1.lcsp"), &good).unwrap(); - assert!(is_valid(&a.join("1.lcsp"))); - for cut in [good.len() - 1, good.len() / 2, 10, 0] { - std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); - assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); - } - assert!(!is_valid(&b.join("missing.lcsp"))); - // moving onto a damaged copy replaces it instead of deleting the good one - assert_eq!(migrate(&a, &b, Existing::Move).0, 1); - assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn camera_tone_travels_with_the_proxy() { - let tone = CameraTone::new(std::array::from_fn(|i| { - let x = 0.004 * 1.17f32.powi(i as i32); - [x, 0.95 * (1.0 - (-2.7 * x).exp())] - })) - .unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() }; - let bytes = encode(&img, Some(&info)).unwrap(); - assert_eq!(decode(&bytes).unwrap().1, Some(tone)); - let dir = temp("tone"); - let path = dir.join("t.lcsp"); - std::fs::write(&path, &bytes).unwrap(); - // the longer header is still read by the validity check, and the curve by `load` - assert!(is_valid(&path)); - let loaded = load(&path).unwrap(); - assert_eq!(loaded.camera_tone, Some(tone)); - let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() }; - assert_eq!(loaded.info_or(header).camera_tone, Some(tone)); - // a hostile curve (reversing knots) is ignored, not trusted - let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); - let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); - let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap(); - knots.swap(3, 4); - head["tone"]["knots"] = serde_json::json!(knots); - let mut hostile = MAGIC.to_vec(); - hostile.extend_from_slice(head.to_string().as_bytes()); - hostile.extend_from_slice(&bytes[nl..]); - let (pixels, tone, _) = decode(&hostile).unwrap(); - assert_eq!((pixels.width, tone), (img.width, None)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn facts_and_mattes_travel_with_the_proxy() { - let img = lightcraft_scenes::demo_library()[0].render(32, 20); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] }); - let info = SourceInfo { - raw: true, - as_shot_temp: 4200.0, - as_shot_tint: -5.0, - legacy_relative_wb: true, - mattes: Some(std::sync::Arc::new(mattes)), - ..Default::default() - }; - let bytes = encode(&img, Some(&info)).unwrap(); - let (_, _, back) = decode(&bytes).unwrap(); - let back = back.expect("the facts are read back"); - assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true)); - assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160); - // a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts) - let dir = temp("facts"); - let path = dir.join("f.lcsp"); - std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap(); - assert!(!is_valid(&path)); - let _ = std::fs::remove_dir_all(&dir); - // an old proxy (no tail) has no facts, and neither has one with a malformed tail - assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none()); - let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); - let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); - head["tail"] = serde_json::json!(bytes.len()); - let mut lying = MAGIC.to_vec(); - lying.extend_from_slice(head.to_string().as_bytes()); - lying.extend_from_slice(&bytes[nl..]); - assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted"); - } - - #[test] - fn roundtrip_keeps_the_picture() { - let img = lightcraft_scenes::demo_library()[0].render(160, 100); - let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap(); - assert_eq!((back.width, back.height), (img.width, img.height)); - assert!(tone.is_none()); - let mut err = 0.0f64; - for (a, b) in img.data.iter().zip(&back.data) { - for k in 0..3 { - let (x, y) = (linear_to_srgb(a[k].clamp(0.0, 1.0)), linear_to_srgb(b[k].clamp(0.0, 1.0))); - err += ((x - y) as f64).abs(); - } - } - let mean = err / (img.data.len() * 3) as f64; - assert!(mean < 0.02, "mean encoded error {mean}"); - assert!(decode(b"nope").is_err()); - } - - /// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into - /// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never - /// enters what is measured. Built from a rendered demo scene so the dimensions and the flat - /// row-major layout are the real ones. - fn smooth_scene(w: usize, h: usize) -> Rgb32f { - let mut img = lightcraft_scenes::demo_library()[0].render(w, h); - let (w, h) = (img.width.max(1), img.height.max(1)); - for (i, px) in img.data.iter_mut().enumerate() { - let u = (i % w) as f32 / (w - 1).max(1) as f32; - let v = (i / w) as f32 / (h - 1).max(1) as f32; - let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step - let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; - let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; - *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); - } - img - } - - /// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The - /// input is clamped into 0..1, the only range a proxy ever stores. - fn lab(c: [f32; 3]) -> [f64; 3] { - let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); - let (x, y, z) = ( - (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, - 0.2126 * r + 0.7152 * g + 0.0722 * b, - (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, - ); - let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; - let (fx, fy, fz) = (f(x), f(y), f(z)); - [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] - } - - /// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a - /// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. - fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { - let n = a.data.len().min(b.data.len()); - if n == 0 { - return 0.0; - } - (0..n) - .map(|i| { - let (p, q) = (lab(a.data[i]), lab(b.data[i])); - ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() - }) - .sum::() - / n as f64 - } - - /// A deliberately frozen copy of the pixel mapping and container this module used before the - /// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the - /// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on - /// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the - /// quality raise bought instead of trusting a written-down number. - fn encode_at_q92(img: &Rgb32f) -> Vec { - let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); - let scale = if lum.is_empty() { - 1.0 - } else { - let k = ((lum.len() as f64) * 0.9995) as usize; - let k = k.min(lum.len() - 1); - let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); - v.max(1.0) - }; - let data: Vec<[u8; 4]> = img - .data - .iter() - .map(|c| { - let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; - [e(c[0]), e(c[1]), e(c[2]), 255] - }) - .collect(); - let rgba = Rgba8 { width: img.width, height: img.height, data }; - let jpg = lightcraft_codecs::encode_jpeg( - &lightcraft_codecs::EncodeImage::rgba8(&rgba), - 92, - lightcraft_codecs::ChromaSubsampling::S444, - &Default::default(), - ) - .unwrap(); - // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field - let mut out = MAGIC.to_vec(); - let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); - out.extend_from_slice(head.to_string().as_bytes()); - out.push(b'\n'); - out.extend_from_slice(&jpg); - out - } - - /// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one - /// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its - /// mean, and rebuilds the whole block from it, so a proxy differs from the original by a - /// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels - /// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share - /// eightfold, which is what a per-pixel ΔE drowns in. - fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { - let w = a.width.min(b.width).max(1); - let h = a.height.min(b.height).max(1); - let (mut sum, mut n) = (0.0f64, 0usize); - for by in (0..h).step_by(8) { - for bx in (0..w).step_by(8) { - let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); - for y in by..(by + 8).min(h) { - for x in bx..(bx + 8).min(w) { - if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { - for k in 0..3 { - sa[k] += f64::from(ca[k]); - sb[k] += f64::from(cb[k]); - } - count += 1; - } - } - } - let d = f64::from(count.max(1)); - let (pa, pb) = ( - lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), - lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), - ); - sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); - n += 1; - } - } - sum / n.max(1) as f64 - } - - /// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit - /// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, - /// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the - /// quality raise is reverted and never trips on the honest result (measured 0.683). - const PROXY_BLOCK_GAIN: f64 = 0.8; - - #[test] - fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { - let img = smooth_scene(256, 160); - let ours = decode(&encode(&img, None).unwrap()).unwrap().0; - let before = decode(&encode_at_q92(&img)).unwrap().0; - let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); - let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); - println!( - "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ - (per-pixel {:.3} vs {:.3})", - block.0, block.1, pixel.0, pixel.1 - ); - assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); - assert!( - block.0 <= PROXY_BLOCK_GAIN * block.1, - "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ - (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", - block.0, - PROXY_BLOCK_GAIN * block.1, - block.1 - ); - } - - #[test] - fn a_proxy_from_an_older_encode_is_stale() { - let dir = temp("encode-version"); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let bytes = encode(&img, None).unwrap(); - let path = dir.join("p.lcsp"); - std::fs::write(&path, &bytes).unwrap(); - assert!(!is_stale(&path), "this build's own proxy is up to date"); - // one written before the encode version was recorded is not - let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); - let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); - assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); - let mut older = head.as_object().cloned().unwrap_or_default(); - older.remove(ENC_FIELD); - let mut old = MAGIC.to_vec(); - old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); - old.extend_from_slice(&bytes[nl..]); - std::fs::write(&path, &old).unwrap(); - assert!(is_valid(&path), "losing the field keeps the file readable"); - assert!(is_stale(&path), "an older encoding is rebuilt"); - // the marker records both versions, and one from before the encode version forces a scan - assert!(!look_checked(&dir)); - mark_look_checked(&dir).unwrap(); - assert!(look_checked(&dir)); - std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); - assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/engine/src/smart/tests.rs b/crates/engine/src/smart/tests.rs new file mode 100644 index 000000000..e4bbd548c --- /dev/null +++ b/crates/engine/src/smart/tests.rs @@ -0,0 +1,349 @@ +use super::*; + +fn temp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn writable_check_does_not_create_missing_parents() { + let base = temp("writable"); + assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created"); + assert!(base.join("new").is_dir()); + let err = check_writable(&base.join("gone/deeper")).unwrap_err(); + assert!(err.contains("not available"), "{err}"); + assert!(!base.join("gone").exists(), "no fallback onto the current drive"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn migrate_moves_leaves_or_discards() { + let base = temp("migrate"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(a.join("1.lcsp"), b"one").unwrap(); + std::fs::write(a.join("note.txt"), b"keep").unwrap(); + assert_eq!(stats(&a), (1, 3)); + assert_eq!(migrate(&a, &b, Existing::Leave).0, 0); + assert_eq!(stats(&a).0, 1); + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!((stats(&a).0, stats(&b).0), (0, 1)); + assert!(a.join("note.txt").exists(), "only smart previews are touched"); + assert_eq!(migrate(&b, &a, Existing::Discard).0, 1); + assert_eq!(stats(&b).0, 0); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn sharded_and_legacy_proxies_are_counted_and_moved() { + let base = temp("shards"); + let from = base.join("from"); + let to = base.join("to"); + std::fs::create_dir_all(from.join("a1")).unwrap(); + std::fs::create_dir(&to).unwrap(); + let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp"; + let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp"; + std::fs::write(from.join(old), b"legacy").unwrap(); + std::fs::write(from.join("a1").join(new), b"sharded").unwrap(); + std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap(); + std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap(); + assert_eq!(stats(&from), (2, 13)); + let (moved, failed) = migrate(&from, &to, Existing::Move); + assert_eq!((moved, failed), (2, Vec::::new())); + assert_eq!(stats(&from), (0, 0)); + assert_eq!(stats(&to), (2, 13)); + assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy"); + assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded"); + assert!(from.join("a1").join("notes.txt").exists()); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { + let base = temp("valid"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let good = encode(&img, None).unwrap(); + std::fs::write(a.join("1.lcsp"), &good).unwrap(); + assert!(is_valid(&a.join("1.lcsp"))); + for cut in [good.len() - 1, good.len() / 2, 10, 0] { + std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); + assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); + } + assert!(!is_valid(&b.join("missing.lcsp"))); + // moving onto a damaged copy replaces it instead of deleting the good one + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn camera_tone_travels_with_the_proxy() { + let tone = CameraTone::new(std::array::from_fn(|i| { + let x = 0.004 * 1.17f32.powi(i as i32); + [x, 0.95 * (1.0 - (-2.7 * x).exp())] + })) + .unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() }; + let bytes = encode(&img, Some(&info)).unwrap(); + assert_eq!(decode(&bytes).unwrap().1, Some(tone)); + let dir = temp("tone"); + let path = dir.join("t.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + // the longer header is still read by the validity check, and the curve by `load` + assert!(is_valid(&path)); + let loaded = load(&path).unwrap(); + assert_eq!(loaded.camera_tone, Some(tone)); + let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() }; + assert_eq!(loaded.info_or(header).camera_tone, Some(tone)); + // a hostile curve (reversing knots) is ignored, not trusted + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap(); + knots.swap(3, 4); + head["tone"]["knots"] = serde_json::json!(knots); + let mut hostile = MAGIC.to_vec(); + hostile.extend_from_slice(head.to_string().as_bytes()); + hostile.extend_from_slice(&bytes[nl..]); + let (pixels, tone, _) = decode(&hostile).unwrap(); + assert_eq!((pixels.width, tone), (img.width, None)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn facts_and_mattes_travel_with_the_proxy() { + let img = lightcraft_scenes::demo_library()[0].render(32, 20); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] }); + let info = SourceInfo { + raw: true, + as_shot_temp: 4200.0, + as_shot_tint: -5.0, + legacy_relative_wb: true, + mattes: Some(std::sync::Arc::new(mattes)), + ..Default::default() + }; + let bytes = encode(&img, Some(&info)).unwrap(); + let (_, _, back) = decode(&bytes).unwrap(); + let back = back.expect("the facts are read back"); + assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true)); + assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160); + // a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts) + let dir = temp("facts"); + let path = dir.join("f.lcsp"); + std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap(); + assert!(!is_valid(&path)); + let _ = std::fs::remove_dir_all(&dir); + // an old proxy (no tail) has no facts, and neither has one with a malformed tail + assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none()); + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + head["tail"] = serde_json::json!(bytes.len()); + let mut lying = MAGIC.to_vec(); + lying.extend_from_slice(head.to_string().as_bytes()); + lying.extend_from_slice(&bytes[nl..]); + assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted"); +} + +#[test] +fn roundtrip_keeps_the_picture() { + let img = lightcraft_scenes::demo_library()[0].render(160, 100); + let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap(); + assert_eq!((back.width, back.height), (img.width, img.height)); + assert!(tone.is_none()); + let mut err = 0.0f64; + for (a, b) in img.data.iter().zip(&back.data) { + for k in 0..3 { + let (x, y) = (linear_to_srgb(a[k].clamp(0.0, 1.0)), linear_to_srgb(b[k].clamp(0.0, 1.0))); + err += ((x - y) as f64).abs(); + } + } + let mean = err / (img.data.len() * 3) as f64; + assert!(mean < 0.02, "mean encoded error {mean}"); + assert!(decode(b"nope").is_err()); +} + +/// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into +/// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never +/// enters what is measured. Built from a rendered demo scene so the dimensions and the flat +/// row-major layout are the real ones. +fn smooth_scene(w: usize, h: usize) -> Rgb32f { + let mut img = lightcraft_scenes::demo_library()[0].render(w, h); + let (w, h) = (img.width.max(1), img.height.max(1)); + for (i, px) in img.data.iter_mut().enumerate() { + let u = (i % w) as f32 / (w - 1).max(1) as f32; + let v = (i / w) as f32 / (h - 1).max(1) as f32; + let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step + let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; + let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; + *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); + } + img +} + +/// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The +/// input is clamped into 0..1, the only range a proxy ever stores. +fn lab(c: [f32; 3]) -> [f64; 3] { + let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); + let (x, y, z) = + ((0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, 0.2126 * r + 0.7152 * g + 0.0722 * b, (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883); + let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a +/// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. +fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let n = a.data.len().min(b.data.len()); + if n == 0 { + return 0.0; + } + (0..n) + .map(|i| { + let (p, q) = (lab(a.data[i]), lab(b.data[i])); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }) + .sum::() + / n as f64 +} + +/// A deliberately frozen copy of the pixel mapping and container this module used before the +/// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the +/// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on +/// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the +/// quality raise bought instead of trusting a written-down number. +fn encode_at_q92(img: &Rgb32f) -> Vec { + let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); + let scale = if lum.is_empty() { + 1.0 + } else { + let k = ((lum.len() as f64) * 0.9995) as usize; + let k = k.min(lum.len() - 1); + let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); + v.max(1.0) + }; + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let rgba = Rgba8 { width: img.width, height: img.height, data }; + let jpg = lightcraft_codecs::encode_jpeg( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + 92, + lightcraft_codecs::ChromaSubsampling::S444, + &Default::default(), + ) + .unwrap(); + // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field + let mut out = MAGIC.to_vec(); + let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + out.extend_from_slice(head.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(&jpg); + out +} + +/// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one +/// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its +/// mean, and rebuilds the whole block from it, so a proxy differs from the original by a +/// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels +/// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share +/// eightfold, which is what a per-pixel ΔE drowns in. +fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let w = a.width.min(b.width).max(1); + let h = a.height.min(b.height).max(1); + let (mut sum, mut n) = (0.0f64, 0usize); + for by in (0..h).step_by(8) { + for bx in (0..w).step_by(8) { + let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); + for y in by..(by + 8).min(h) { + for x in bx..(bx + 8).min(w) { + if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { + for k in 0..3 { + sa[k] += f64::from(ca[k]); + sb[k] += f64::from(cb[k]); + } + count += 1; + } + } + } + let d = f64::from(count.max(1)); + let (pa, pb) = ( + lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), + lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), + ); + sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); + n += 1; + } + } + sum / n.max(1) as f64 +} + +/// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit +/// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, +/// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the +/// quality raise is reverted and never trips on the honest result (measured 0.683). +const PROXY_BLOCK_GAIN: f64 = 0.8; + +#[test] +fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { + let img = smooth_scene(256, 160); + let ours = decode(&encode(&img, None).unwrap()).unwrap().0; + let before = decode(&encode_at_q92(&img)).unwrap().0; + let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); + let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); + println!( + "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ + (per-pixel {:.3} vs {:.3})", + block.0, block.1, pixel.0, pixel.1 + ); + assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); + assert!( + block.0 <= PROXY_BLOCK_GAIN * block.1, + "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ + (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", + block.0, + PROXY_BLOCK_GAIN * block.1, + block.1 + ); +} + +#[test] +fn a_proxy_from_an_older_encode_is_stale() { + let dir = temp("encode-version"); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let bytes = encode(&img, None).unwrap(); + let path = dir.join("p.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + assert!(!is_stale(&path), "this build's own proxy is up to date"); + // one written before the encode version was recorded is not + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); + let mut older = head.as_object().cloned().unwrap_or_default(); + older.remove(ENC_FIELD); + let mut old = MAGIC.to_vec(); + old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); + old.extend_from_slice(&bytes[nl..]); + std::fs::write(&path, &old).unwrap(); + assert!(is_valid(&path), "losing the field keeps the file readable"); + assert!(is_stale(&path), "an older encoding is rebuilt"); + // the marker records both versions, and one from before the encode version forces a scan + assert!(!look_checked(&dir)); + mark_look_checked(&dir).unwrap(); + assert!(look_checked(&dir)); + std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); + assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 93f4e6adc..ae03859d2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2,6 +2,53 @@ use serde_json::{Value, json}; use crate::{Selection, Session, command_specs}; +mod album_order; +mod album_remove; +mod albums_local; +mod color; +mod constrain_crop; +mod crop_angle; +mod crop_aspect; +mod denoise; +mod export; +mod face_models; +#[cfg(not(target_arch = "wasm32"))] +pub(super) mod face_recognize; +#[cfg(not(target_arch = "wasm32"))] +mod face_tiles; +mod filesystem_history; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod focus_scan; +mod folders; +mod forget_local; +mod history_cursor; +mod hold_view; +mod ids_params; +mod import; +mod import_move; +mod keyword_list; +mod libops; +mod library; +mod lightroom_folder; +mod mask_effects; +mod merge; +mod move_to_share; +mod organize; +mod persist; +mod prefs; +mod presets_scale; +mod print; +mod process; +mod recheck; +mod segment; +mod settings_files; +mod slide; +mod slideshow_export; +mod spots; +mod sync; +mod view_follow; +pub(super) mod xmp; + fn demo() -> Session { Session::with_demo() } @@ -554,6 +601,12 @@ fn degenerate_crops_render_with_spots_and_masks() { s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap(); s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap(); set_crop_unchecked(&mut s, rect, 30.0); + // Measure the degenerate render, not procedural demo generation. Load exactly the + // source level this crop requests; keep the original photo, effects and output size. + let level = s.render_job(id, 700, 450, false, true).unwrap().level; + let source = s.source_now(id, level).unwrap(); + assert!(source.width > 0 && source.height > 0); + assert!(s.media.has_source(id, level), "the timed render must reuse the prepared source"); let t = std::time::Instant::now(); let r = s.render_now(id, 700, 450).unwrap(); assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}"); @@ -1147,18 +1200,51 @@ fn preview_build_shows_in_activity_until_it_finishes() { assert_eq!(s.execute("library.previewProgress", &json!({})).unwrap()["running"], false); } -/// Issue #345: `activity.cancel` stops a preview build like `library.cancelPreviews`. +/// Issue #345: `activity.cancel` stops a preview build like `library.cancelPreviews`. A test-only +/// gate holds the lane at its first photo, so the cancel lands before any render instead of racing +/// a 24 MP one; the accepted cancel must not count that photo. #[test] fn activity_cancel_stops_a_preview_build() { + use crate::cmd::previews::test_gate::{self, PreviewGate, ReleaseOnDrop}; let mut s = demo(); - let ids: Vec = s.catalog.photos().map(|p| p.id.0).collect(); - s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + let gate = PreviewGate::new(); + // also opens the gate if an assertion below panics, so the worker is never stranded + let release = ReleaseOnDrop(gate.clone()); + test_gate::install(gate.clone()); + s.execute("library.buildPreviews", &json!({"ids": ids, "size": "standard", "edge": 256})).unwrap(); let id = s.activity.list().first().map(|t| t.id).expect("a previews row"); + assert!(gate.wait_entered(1), "a lane reached its first photo"); s.execute("activity.cancel", &json!({"id": id})).unwrap(); + let row = s.activity.list().into_iter().find(|t| t.id == id).expect("the row stays while the lane is held"); + assert!(row.cancelling, "the row shows the accepted cancel: {row:?}"); + drop(release); wait_activity_empty(&s); let p = s.execute("library.previewProgress", &json!({})).unwrap(); assert_eq!(p["cancelled"], true, "{p}"); - assert!(p["done"].as_u64() < p["total"].as_u64(), "stopped part-way: {p}"); + assert_eq!(p["done"], json!(0), "no photo rendered after the accepted cancel: {p}"); + assert_eq!(p["running"], false, "{p}"); +} + +/// The gated fixture is real work: without a cancel, both photos render and are counted. +#[test] +fn preview_build_at_the_test_gate_completes_without_cancel() { + use crate::cmd::previews::test_gate::{self, PreviewGate, ReleaseOnDrop}; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + let gate = PreviewGate::new(); + let release = ReleaseOnDrop(gate.clone()); + test_gate::install(gate.clone()); + let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "standard", "edge": 256})).unwrap(); + assert_eq!(r["total"], json!(2), "{r}"); + assert!(gate.wait_entered(1), "a lane reached its first photo"); + drop(release); + wait_activity_empty(&s); + let p = s.execute("library.previewProgress", &json!({})).unwrap(); + assert_eq!(p["running"], false, "{p}"); + assert_eq!(p["cancelled"], false, "{p}"); + assert_eq!(p["done"], json!(2), "both photos rendered: {p}"); + assert_eq!(p["failed"], json!(0), "{p}"); } /// Colour range: a click samples the colour under it, so the mask selects that colour (white diff --git a/crates/engine/src/tests_album_order.rs b/crates/engine/src/tests/album_order.rs similarity index 100% rename from crates/engine/src/tests_album_order.rs rename to crates/engine/src/tests/album_order.rs diff --git a/crates/engine/src/tests_album_remove.rs b/crates/engine/src/tests/album_remove.rs similarity index 100% rename from crates/engine/src/tests_album_remove.rs rename to crates/engine/src/tests/album_remove.rs diff --git a/crates/engine/src/tests_albums_local.rs b/crates/engine/src/tests/albums_local.rs similarity index 100% rename from crates/engine/src/tests_albums_local.rs rename to crates/engine/src/tests/albums_local.rs diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests/color.rs similarity index 100% rename from crates/engine/src/tests_color.rs rename to crates/engine/src/tests/color.rs diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests/constrain_crop.rs similarity index 100% rename from crates/engine/src/tests_constrain_crop.rs rename to crates/engine/src/tests/constrain_crop.rs diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests/crop_angle.rs similarity index 100% rename from crates/engine/src/tests_crop_angle.rs rename to crates/engine/src/tests/crop_angle.rs diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests/crop_aspect.rs similarity index 100% rename from crates/engine/src/tests_crop_aspect.rs rename to crates/engine/src/tests/crop_aspect.rs diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests/denoise.rs similarity index 100% rename from crates/engine/src/tests_denoise.rs rename to crates/engine/src/tests/denoise.rs diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests/export.rs similarity index 100% rename from crates/engine/src/tests_export.rs rename to crates/engine/src/tests/export.rs diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests/face_models.rs similarity index 100% rename from crates/engine/src/tests_face_models.rs rename to crates/engine/src/tests/face_models.rs diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests/face_recognize.rs similarity index 85% rename from crates/engine/src/tests_face_recognize.rs rename to crates/engine/src/tests/face_recognize.rs index b24a7bbb9..f6ca87494 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests/face_recognize.rs @@ -18,10 +18,24 @@ pub(crate) fn temp(name: &str) -> PathBuf { d } +/// Seed CPU-only settings before installing models in a fresh test folder. These tests cover catalog and +/// model logic; GPU kernels have separate tests. Calling the settings mutation command here would also +/// detach the process-wide detector accelerator, interfering with other tests in the same process. +fn keep_on_cpu(s: &mut Session) { + let dir = s.face_models_dir.as_ref().unwrap(); + let settings = dir.join("settings.json"); + assert!(!settings.exists(), "only seed a fresh fixture's settings"); + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(settings, br#"{"cpuOnly":true}"#).unwrap(); + let r = s.execute("faces.gpu", &json!({})).unwrap(); + assert_eq!(r["cpuOnly"], true, "the test session must run face models on the CPU: {r}"); +} + /// A demo session with a tiny model installed and chosen. pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) { let mut s = Session::with_demo(); s.face_models_dir = Some(d.join("models")); + keep_on_cpu(&mut s); let model = d.join(format!("tiny{dim}.onnx")); std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(dim)).unwrap(); let r = s.execute("faces.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap(); @@ -30,6 +44,47 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) { (s, id) } +/// A fresh empty library with the tiny model installed and chosen. The tests below only need a decodable +/// picture (and enough pixels for an optional real detector), not the demo's 12 procedural 24 MP scenes: +/// each demo photo costs a full develop render before the 112 px model input. +fn fresh_library(d: &std::path::Path, dim: u64) -> (Session, PathBuf) { + let lib = d.join("lib"); + let mut s = Session::new().with_fs(); + s.face_models_dir = Some(d.join("models")); + keep_on_cpu(&mut s); + s.open_library(&lib, false).unwrap(); + let model = d.join(format!("tiny{dim}.onnx")); + std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(dim)).unwrap(); + s.execute("faces.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap(); + (s, lib) +} + +/// A real, distinct 1024×768 PNG (seed picks its colours). +fn write_photo(path: &std::path::Path, seed: u8) { + let (w, h) = (1024usize, 768usize); + let data: Vec<[u8; 4]> = (0..w * h) + .map(|i| [((i % w * 3 + seed as usize * 17) % 256) as u8, ((i / w * 5 + seed as usize * 29) % 256) as u8, seed.wrapping_mul(41), 255]) + .collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// Import three distinct real file-backed photos: two for faces and one with none for the optional detector. +fn import_three_photos(s: &mut Session, d: &std::path::Path) -> (PhotoId, PhotoId, PhotoId) { + let mut paths = Vec::new(); + for (i, name) in ["ann.png", "bea.png", "empty.png"].iter().enumerate() { + let path = d.join("photos").join(name); + write_photo(&path, i as u8 + 1); + paths.push(path.to_string_lossy().into_owned()); + } + let r = s.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| PhotoId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 3, "three real photos imported: {r}"); + (ids[0], ids[1], ids[2]) +} + /// Installs the real YuNet from `LC_YUNET_MODEL=` (what Settings > Faces downloads; nothing is /// committed) and says whether it did: the parts of a test that need to find faces run only then. pub(crate) fn install_yunet(s: &mut Session) -> bool { @@ -61,35 +116,52 @@ pub(crate) fn two_photos(s: &Session) -> (PhotoId, PhotoId) { #[test] fn indexing_embeds_each_face_once_and_reports_progress() { let d = temp("index"); - let (mut s, _) = setup(&d, 64); - let (a, b) = two_photos(&s); + let (mut s, _) = fresh_library(&d, 64); + let (a, b, c) = import_three_photos(&mut s, &d); set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]); set_regions(&mut s, b, vec![region(0.3, None)]); // only a report let r = s.execute("faces.index", &json!({"budgetMs": 0})).unwrap(); assert_eq!((r["embedded"].clone(), r["photosDone"].clone(), r["pendingPhotos"].clone()), (json!(0), json!(0), json!(2)), "{r}"); - // a generous budget does it all, the named photo first - let r = s.execute("faces.index", &json!({"budgetMs": 60_000})).unwrap(); - assert_eq!((r["embedded"].clone(), r["pendingPhotos"].clone(), r["indexedFaces"].clone()), (json!(3), json!(0), json!(3)), "{r}"); + // `faces.index` stops between whole photos once the budget is spent, so follow its documented contract: + // call again until `pendingPhotos` is 0. Two photos need at most two productive calls; the named photo is + // first in the queue, and every face is embedded exactly once. + let mut embedded = 0u64; + for call in 0..2 { + let r = s.execute("faces.index", &json!({"budgetMs": 60_000})).unwrap(); + embedded += r["embedded"].as_u64().unwrap(); + if call == 0 { + assert!(s.faces.index.contains(a.0, ®ion(0.1, Some("Ann")).rect), "the named photo is done first: {r}"); + assert!(s.faces.index.contains(a.0, ®ion(0.5, None).rect), "both of its faces are embedded: {r}"); + } + if r["pendingPhotos"] == 0 { + break; + } + } + let r = s.execute("faces.index", &json!({"budgetMs": 0})).unwrap(); + assert_eq!((r["pendingPhotos"].clone(), r["indexedFaces"].clone()), (json!(0), json!(3)), "{r}"); + assert_eq!(embedded, 3, "each of the three faces was embedded exactly once across calls"); // nothing is embedded twice let r = s.execute("faces.index", &json!({"budgetMs": 60_000})).unwrap(); assert_eq!((r["embedded"].clone(), r["photosDone"].clone()), (json!(0), json!(0))); // a small budget still makes progress (at least one photo per call), and `ids` go first - let c = s.catalog.photos().map(|p| p.id).find(|id| *id != a && *id != b).unwrap(); set_regions(&mut s, c, vec![region(0.2, None)]); set_regions(&mut s, b, vec![region(0.3, None), region(0.6, None)]); let r = s.execute("faces.index", &json!({"budgetMs": 1, "ids": [c.0]})).unwrap(); assert_eq!(r["photosDone"], 1, "{r}"); assert_eq!(r["pendingPhotos"], 1, "{r}"); assert!(s.faces.index.contains(c.0, ®ion(0.2, None).rect), "the photo asked for first was done first"); + // keep the imported files until the session that references them is gone + drop(s); let _ = std::fs::remove_dir_all(&d); } #[test] fn suggestions_come_from_the_named_faces_and_are_never_applied() { let d = temp("suggest"); - let (mut s, _) = setup(&d, 64); - let (a, b) = two_photos(&s); + // Gallery and candidate semantics need real embeddings, not full 24 MP demo renders. + let (mut s, _) = fresh_library(&d, 64); + let (a, b, _) = import_three_photos(&mut s, &d); set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]); set_regions(&mut s, b, vec![region(0.1, None), region(0.5, Some("Pet?")), region(0.7, None)]); // thresholds that accept anything: Ann is the only person with a named face except "Pet?" @@ -173,6 +245,7 @@ fn embeddings_are_cached_beside_the_library_and_reset_for_another_model() { }; let mut s = Session::new().with_fs(); s.face_models_dir = Some(models.clone()); + keep_on_cpu(&mut s); s.open_library(&lib, true).unwrap(); let (a, _) = two_photos(&s); set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]); @@ -214,8 +287,9 @@ fn without_a_chosen_model_recognition_says_so() { #[test] fn the_background_pump_indexes_by_itself_once_recognition_is_on() { let d = temp("pump"); - let (mut s, _) = setup(&d, 64); - let (a, b) = two_photos(&s); + // Exercise real background embedding without unrelated full 24 MP demo renders. + let (mut s, _) = fresh_library(&d, 64); + let (a, b, _) = import_three_photos(&mut s, &d); set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]); set_regions(&mut s, b, vec![region(0.3, None)]); // installing the model switched recognition on; switched off, the pump does nothing, and says so @@ -251,31 +325,33 @@ fn the_background_pump_indexes_by_itself_once_recognition_is_on() { #[test] fn the_background_pump_saves_what_it_embeds_for_the_next_launch() { let d = temp("pumpsave"); - let (lib, models) = (d.join("lib"), d.join("models")); - let mut s = Session::new().with_fs(); - s.face_models_dir = Some(models); - s.open_library(&lib, true).unwrap(); - let model = d.join("tiny64.onnx"); - std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(64)).unwrap(); - // installing switches recognition on: nothing else is asked for - s.execute("faces.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap(); + // Installing the tiny model switches recognition on: nothing else is asked for. + let (mut s, lib) = fresh_library(&d, 64); // without the detector (not bundled) photos without faces are left unsearched let searching = install_yunet(&mut s); - let (a, b) = two_photos(&s); + let (a, b, c) = import_three_photos(&mut s, &d); set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]); set_regions(&mut s, b, vec![region(0.3, None)]); + assert!(s.catalog.photo(c).unwrap().meta.regions.is_empty(), "the third photo has no face to find"); let photos = s.catalog.photos().count(); - let searched_photos = if searching { photos - 2 } else { 0 }; + assert_eq!(photos, 3, "a fresh library holds only the three imported photos"); + // only the one photo without a face region is searched when the optional detector is installed + let searched_photos = usize::from(searching); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); - let mut last = json!(null); + let (mut last, mut embedded) = (json!(null), 0u64); while std::time::Instant::now() < deadline { last = s.execute("faces.pump", &json!({})).unwrap(); + embedded += last["embedded"].as_u64().unwrap_or(0); if last["pendingPhotos"] == 0 && s.faces.index.len() == 3 && s.faces.scanned.len() == searched_photos { break; } std::thread::sleep(std::time::Duration::from_millis(50)); } assert_eq!((s.faces.index.len(), s.faces.scanned.len()), (3, searched_photos), "{last}"); + assert_eq!(embedded, 3, "the background worker embedded each face exactly once: {last}"); + // once everything is done another pump starts no work and reports no new embedding + let idle = s.execute("faces.pump", &json!({})).unwrap(); + assert_eq!((idle["embedded"].clone(), idle["inFlight"].clone(), idle["pendingPhotos"].clone()), (json!(0), json!(0), json!(0)), "{idle}"); let (tag, path) = (s.faces.index.tag.clone(), s.faces.index.path.clone().unwrap()); assert!(path.file_name().unwrap().to_string_lossy().starts_with("face-embeddings-custom-"), "one cache file per model: {}", path.display()); // the first batch is written at once; whatever is left is written when the session ends, so a launch never redoes @@ -491,12 +567,9 @@ fn every_scanned_face_is_shown_by_the_same_kind_of_box() { #[test] fn face_scan_row_follows_pending_photos() { let d = temp("activity"); - let (mut s, _) = setup(&d, 64); - // the last two demo photos, not the first two the other scan tests use: two sessions scanning the same demo scene - // at once can deadlock (issue #630), and this test is about the row, not that - let mut ids = s.catalog.photos().map(|p| p.id).collect::>(); - ids.sort(); - let (a, b) = (ids[ids.len() - 2], ids[ids.len() - 1]); + // The activity row needs real background embedding, not full 24 MP demo renders. + let (mut s, _) = fresh_library(&d, 64); + let (a, b, _) = import_three_photos(&mut s, &d); set_regions(&mut s, a, vec![region(0.1, None), region(0.5, None)]); set_regions(&mut s, b, vec![region(0.3, None)]); let faces_rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "faces").collect::>(); diff --git a/crates/engine/src/tests_face_tiles.rs b/crates/engine/src/tests/face_tiles.rs similarity index 100% rename from crates/engine/src/tests_face_tiles.rs rename to crates/engine/src/tests/face_tiles.rs diff --git a/crates/engine/src/tests_filesystem_history.rs b/crates/engine/src/tests/filesystem_history.rs similarity index 100% rename from crates/engine/src/tests_filesystem_history.rs rename to crates/engine/src/tests/filesystem_history.rs diff --git a/crates/engine/src/tests_focus_scan.rs b/crates/engine/src/tests/focus_scan.rs similarity index 100% rename from crates/engine/src/tests_focus_scan.rs rename to crates/engine/src/tests/focus_scan.rs diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests/folders.rs similarity index 100% rename from crates/engine/src/tests_folders.rs rename to crates/engine/src/tests/folders.rs diff --git a/crates/engine/src/tests_forget_local.rs b/crates/engine/src/tests/forget_local.rs similarity index 100% rename from crates/engine/src/tests_forget_local.rs rename to crates/engine/src/tests/forget_local.rs diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests/history_cursor.rs similarity index 100% rename from crates/engine/src/tests_history_cursor.rs rename to crates/engine/src/tests/history_cursor.rs diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests/hold_view.rs similarity index 100% rename from crates/engine/src/tests_hold_view.rs rename to crates/engine/src/tests/hold_view.rs diff --git a/crates/engine/src/tests_ids_params.rs b/crates/engine/src/tests/ids_params.rs similarity index 100% rename from crates/engine/src/tests_ids_params.rs rename to crates/engine/src/tests/ids_params.rs diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests/import.rs similarity index 100% rename from crates/engine/src/tests_import.rs rename to crates/engine/src/tests/import.rs diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests/import_move.rs similarity index 100% rename from crates/engine/src/tests_import_move.rs rename to crates/engine/src/tests/import_move.rs diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests/keyword_list.rs similarity index 92% rename from crates/engine/src/tests_keyword_list.rs rename to crates/engine/src/tests/keyword_list.rs index b98b45de8..94ff59988 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests/keyword_list.rs @@ -263,6 +263,29 @@ fn set_names_match_whatever_their_case() { assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted"); } +/// Next / Previous Keyword Set (⌥0 / ⌥⇧0) step through the sets in the Keyword Set menu's order, +/// Recent Keywords first, and wrap around at either end; with no saved sets they stay on Recent +/// Keywords. +#[test] +fn next_and_previous_keyword_set_wrap_around_the_sets() { + let mut s = Session::with_demo(); + let current = |r: serde_json::Value| r["current"].as_str().unwrap_or_default().to_string(); + assert_eq!(current(s.execute("keyword.nextSet", &json!({})).unwrap()), "Recent Keywords", "no sets: nothing to go to"); + assert_eq!(current(s.execute("keyword.previousSet", &json!({})).unwrap()), "Recent Keywords"); + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbor"]})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + let forward: Vec = (0..3).map(|_| current(s.execute("keyword.nextSet", &json!({})).unwrap())).collect(); + assert_eq!(forward, ["Weddings", "Travel", "Recent Keywords"]); + let back: Vec = (0..3).map(|_| current(s.execute("keyword.previousSet", &json!({})).unwrap())).collect(); + assert_eq!(back, ["Travel", "Weddings", "Recent Keywords"]); + let r = s.execute("keyword.nextSet", &json!({})).unwrap(); + assert_eq!(r["keywords"], json!(["ceremony"]), "⌥1–⌥9 now apply the set's keywords"); + // a current set that is no longer there counts as Recent Keywords + s.keyword_set = Some("Gone".into()); + assert_eq!(current(s.execute("keyword.nextSet", &json!({})).unwrap()), "Weddings"); +} + /// Export Keywords writes the keyword list file, and names the keywords Capture One's importer /// refuses (it allows none of ; , < > in a list); without a path it gives the text. #[test] diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests/libops.rs similarity index 100% rename from crates/engine/src/tests_libops.rs rename to crates/engine/src/tests/libops.rs diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests/library.rs similarity index 100% rename from crates/engine/src/tests_library.rs rename to crates/engine/src/tests/library.rs diff --git a/crates/engine/src/tests_lightroom_folder.rs b/crates/engine/src/tests/lightroom_folder.rs similarity index 100% rename from crates/engine/src/tests_lightroom_folder.rs rename to crates/engine/src/tests/lightroom_folder.rs diff --git a/crates/engine/src/tests_mask_effects.rs b/crates/engine/src/tests/mask_effects.rs similarity index 100% rename from crates/engine/src/tests_mask_effects.rs rename to crates/engine/src/tests/mask_effects.rs diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests/merge.rs similarity index 100% rename from crates/engine/src/tests_merge.rs rename to crates/engine/src/tests/merge.rs diff --git a/crates/engine/src/tests_move_to_share.rs b/crates/engine/src/tests/move_to_share.rs similarity index 100% rename from crates/engine/src/tests_move_to_share.rs rename to crates/engine/src/tests/move_to_share.rs diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests/organize.rs similarity index 100% rename from crates/engine/src/tests_organize.rs rename to crates/engine/src/tests/organize.rs diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests/persist.rs similarity index 100% rename from crates/engine/src/tests_persist.rs rename to crates/engine/src/tests/persist.rs diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests/prefs.rs similarity index 99% rename from crates/engine/src/tests_prefs.rs rename to crates/engine/src/tests/prefs.rs index 4c9d9582d..c0ded23dd 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -403,7 +403,7 @@ fn slideshow_playback_settings_persist_with_the_library() { let r = s .execute( "slideshow.playback", - &json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}), + &json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "manual": true}), ) .unwrap(); assert_eq!(r["fadeColor"], "#000000"); @@ -417,7 +417,7 @@ fn slideshow_playback_settings_persist_with_the_library() { let r = s.execute("slideshow.playback", &json!({})).unwrap(); assert_eq!( r, - json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true}) + json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true, "manual": true}) ); drop(s); // out-of-range values written by hand are clamped when the library opens diff --git a/crates/engine/src/tests_presets_scale.rs b/crates/engine/src/tests/presets_scale.rs similarity index 100% rename from crates/engine/src/tests_presets_scale.rs rename to crates/engine/src/tests/presets_scale.rs diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests/print.rs similarity index 75% rename from crates/engine/src/tests_print.rs rename to crates/engine/src/tests/print.rs index 51277da04..5a35adb5b 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests/print.rs @@ -14,6 +14,24 @@ fn fails(s: &mut Session, id: &str, p: Value) -> String { s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string() } +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lightcraft-print-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + fn package(s: &mut Session) { run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}})); } @@ -323,6 +341,125 @@ fn templates_save_apply_delete_and_persist() { assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); } +#[test] +fn templates_export_to_a_file_and_import_from_one() { + let files = MemStore::new(); + let open = |files: &MemStore| { + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s + }; + let scratch = Scratch::new("templates"); + let dir = &scratch.0; + let mut s = open(&files); + run(&mut s, "print.set", json!({"layout": {"style": "contactSheet", "grid": {"columns": 3, "rows": 4}}, "page": {"background": [5, 6, 7]}})); + run(&mut s, "print.template.save", json!({"name": "Proofs"})); + + // the extension is added when the path has none + let r = run(&mut s, "print.template.export", json!({"name": "proofs", "path": dir.join("proofs").to_string_lossy()})); + assert_eq!(r["name"], "Proofs"); + let file = dir.join("proofs.lcprint"); + assert_eq!(r["path"], file.to_string_lossy().as_ref()); + let doc: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some("lightcraft.printTemplate"), Some(1), Some("Proofs"))); + + // into the same library: the name is taken, so the copy is renamed + let r = run(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs (2)"), Some(true)), "{r}"); + assert_eq!(r["templates"].as_array().unwrap().len(), 2); + assert_eq!(r["templates"][1]["settings"], r["templates"][0]["settings"]); + + // into another library, under its own name, and it is kept there + let other = MemStore::new(); + let mut b = open(&other); + let r = run(&mut b, "print.template.import", json!({"path": file.to_string_lossy()})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs"), Some(false))); + let g = run(&mut open(&other), "print.template.apply", json!({"name": "Proofs"})); + assert_eq!(g["settings"]["layout"]["grid"]["columns"], 3); + assert_eq!(g["settings"]["page"]["background"], json!([5, 6, 7])); + + // refusals leave the templates as they were + let bad_file = dir.join("bad.lcprint"); + std::fs::write(&bad_file, br#"{"format": "lightcraft.printTemplate", "version": 1, "name": "Bad", "settings": {"layout": {"pages": 0}}}"#) + .unwrap(); + let huge = dir.join("huge.lcprint"); + std::fs::write(&huge, vec![b' '; crate::print::template::MAX_FILE as usize + 1]).unwrap(); + for (p, why) in [ + (json!({}), "missing `path`"), + (json!({"path": " "}), "missing `path`"), + (json!({"path": dir.join("absent.lcprint").to_string_lossy()}), "absent.lcprint"), + (json!({"path": dir.to_string_lossy()}), "not a file"), + (json!({"path": huge.to_string_lossy()}), "too large"), + (json!({"path": bad_file.to_string_lossy()}), "template `Bad`"), + ] { + let e = fails(&mut s, "print.template.import", p.clone()); + assert!(e.contains(why), "{p}: {e}"); + } + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 2); + assert!(fails(&mut s, "print.template.export", json!({"name": "Nope", "path": dir.join("x").to_string_lossy()})).contains("no print template")); + assert!(fails(&mut s, "print.template.export", json!({"name": "Proofs"})).contains("missing `path`")); + assert!(fails(&mut s, "print.template.export", json!({"path": dir.join("x").to_string_lossy()})).contains("missing `name`")); + assert!(!dir.join("x.lcprint").exists()); + + // a full list takes no more + while s.print_templates.len() < crate::print::template::MAX_TEMPLATES { + let n = s.print_templates.len(); + run(&mut s, "print.template.save", json!({"name": format!("T{n}")})); + } + assert!(fails(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})).contains("at most")); +} + +/// A library whose `prefs.json` can't be read, so saving the preferences fails. +#[derive(Clone, Default)] +struct PrefsUnreadable(MemStore); + +impl lightcraft_catalog::Store for PrefsUnreadable { + fn read(&mut self, name: &str) -> std::io::Result>> { + if name == "prefs.json" { + return Err(std::io::Error::other("locked by another process")); + } + self.0.read(name) + } + fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> { + self.0.write_atomic(name, data) + } + fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> { + self.0.append(name, data) + } + fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> { + self.0.truncate(name, len) + } + fn describe(&self) -> String { + "prefs unreadable".into() + } +} + +#[test] +fn template_changes_that_cant_be_saved_are_undone() { + let mut s = Session::with_demo(); + let stores = + LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(PrefsUnreadable::default()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s.print_templates.push(crate::print::template::PrintTemplate::new("Proofs", &s.print).unwrap()); + let names = |s: &Session| s.print_templates.iter().map(|t| (t.name.clone(), t.settings.clone())).collect::>(); + let kept = names(&s); + let scratch = Scratch::new("unsaved"); + let file = scratch.0.join("proofs.lcprint"); + run(&mut s, "print.template.export", json!({"name": "Proofs", "path": file.to_string_lossy()})); + + run(&mut s, "print.set", json!({"layout": {"style": "contactSheet"}})); + for (id, p) in [ + ("print.template.save", json!({"name": "Other"})), + ("print.template.save", json!({"name": "proofs"})), + ("print.template.delete", json!({"name": "Proofs"})), + ("print.template.import", json!({"path": file.to_string_lossy()})), + ] { + assert!(fails(&mut s, id, p.clone()).contains("prefs.json"), "{id} {p}"); + assert_eq!(names(&s), kept, "{id} {p}"); + } +} + #[test] fn a_hostile_template_in_the_preferences_does_not_break_the_library() { let files = MemStore::new(); @@ -364,6 +501,8 @@ fn every_print_command_is_registered_with_a_description() { "print.template.save", "print.template.apply", "print.template.delete", + "print.template.export", + "print.template.import", ] { let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing")); assert!(c.params.len() > 3, "{id}"); diff --git a/crates/engine/src/tests_process.rs b/crates/engine/src/tests/process.rs similarity index 100% rename from crates/engine/src/tests_process.rs rename to crates/engine/src/tests/process.rs diff --git a/crates/engine/src/tests_recheck.rs b/crates/engine/src/tests/recheck.rs similarity index 100% rename from crates/engine/src/tests_recheck.rs rename to crates/engine/src/tests/recheck.rs diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests/segment.rs similarity index 100% rename from crates/engine/src/tests_segment.rs rename to crates/engine/src/tests/segment.rs diff --git a/crates/engine/src/tests_settings_files.rs b/crates/engine/src/tests/settings_files.rs similarity index 100% rename from crates/engine/src/tests_settings_files.rs rename to crates/engine/src/tests/settings_files.rs diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests/slide.rs similarity index 100% rename from crates/engine/src/tests_slide.rs rename to crates/engine/src/tests/slide.rs diff --git a/crates/engine/src/tests_slideshow_export.rs b/crates/engine/src/tests/slideshow_export.rs similarity index 100% rename from crates/engine/src/tests_slideshow_export.rs rename to crates/engine/src/tests/slideshow_export.rs diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests/spots.rs similarity index 100% rename from crates/engine/src/tests_spots.rs rename to crates/engine/src/tests/spots.rs diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests/sync.rs similarity index 100% rename from crates/engine/src/tests_sync.rs rename to crates/engine/src/tests/sync.rs diff --git a/crates/engine/src/tests_view_follow.rs b/crates/engine/src/tests/view_follow.rs similarity index 100% rename from crates/engine/src/tests_view_follow.rs rename to crates/engine/src/tests/view_follow.rs diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests/xmp.rs similarity index 100% rename from crates/engine/src/tests_xmp.rs rename to crates/engine/src/tests/xmp.rs diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 430335840..bd093c495 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -620,399 +620,7 @@ fn reshape(node: &Node, x: &Tensor, g: &Graph) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::graph::{Attr, Weight}; - - fn node(op: &str, inputs: &[&str], attrs: &[(&str, Attr)]) -> Node { - Node { - op: op.into(), - inputs: inputs.iter().map(|s| s.to_string()).collect(), - outputs: vec!["y".into()], - attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), - } - } - fn graph(nodes: Vec, weights: Vec<(&str, Weight)>) -> Graph { - Graph { - nodes, - weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(), - inputs: vec![("x".into(), vec![1, 1, 3, 3])], - outputs: vec!["y".into()], - } - } - fn w(dims: &[usize], v: Vec) -> Weight { - Weight { dims: dims.to_vec(), data: Data::F32(v) } - } - fn input(c: usize, h: usize, wd: usize, f: impl Fn(usize) -> f32) -> Tensor { - Tensor::new(vec![1, c, h, wd], (0..c * h * wd).map(f).collect()).unwrap() - } - fn run(g: Graph, x: Tensor) -> Result { - Ok(Net::new(g)?.run(x)?.remove(0).1) - } - - /// A direct, slow reference convolution to check the fast one against. - fn reference_conv(x: &Tensor, wd: &[f32], dims: [usize; 4], bias: &[f32], s: usize, p: usize, groups: usize) -> Tensor { - let (c, h, wi) = (x.shape[1], x.shape[2], x.shape[3]); - let [co_n, cin_g, kh, kw] = dims; - let (oh, ow) = ((h + 2 * p - kh) / s + 1, (wi + 2 * p - kw) / s + 1); - let mut out = vec![0.0f32; co_n * oh * ow]; - for co in 0..co_n { - let g = co / (co_n / groups); - for oy in 0..oh { - for ox in 0..ow { - let mut acc = bias[co]; - for cl in 0..cin_g { - for ky in 0..kh { - for kx in 0..kw { - let (iy, ix) = ((oy * s + ky) as isize - p as isize, (ox * s + kx) as isize - p as isize); - if iy >= 0 && ix >= 0 && (iy as usize) < h && (ix as usize) < wi { - acc += - x.data[((g * cin_g + cl) * h + iy as usize) * wi + ix as usize] * wd[((co * cin_g + cl) * kh + ky) * kw + kx]; - } - } - } - } - out[(co * oh + oy) * ow + ox] = acc; - } - } - } - let _ = c; - Tensor::new(vec![1, co_n, oh, ow], out).unwrap() - } - - #[test] - fn convolutions_match_a_slow_reference() { - // (cin, cout, groups, kernel, stride, pad, h, w) - for (cin, cout, groups, k, s, p, h, wi) in [ - (3, 4, 1, 3, 1, 1, 7, 9), - (3, 4, 1, 3, 2, 1, 8, 8), - (4, 4, 4, 3, 1, 1, 6, 5), - (2, 6, 1, 1, 1, 0, 5, 5), - (4, 8, 2, 3, 1, 1, 5, 6), - (1, 1, 1, 3, 1, 0, 3, 3), - ] { - let cin_g = cin / groups; - let wd: Vec = (0..cout * cin_g * k * k).map(|i| ((i * 37 % 11) as f32 - 5.0) * 0.1).collect(); - let bias: Vec = (0..cout).map(|i| i as f32 * 0.25 - 0.5).collect(); - let x = input(cin, h, wi, |i| ((i * 13 % 17) as f32 - 8.0) * 0.2); - let attrs = [("group", Attr::Int(groups as i64)), ("strides", Attr::Ints(vec![s as i64; 2])), ("pads", Attr::Ints(vec![p as i64; 4]))]; - let g = graph( - vec![node("Conv", &["x", "w", "b"], &attrs)], - vec![("w", w(&[cout, cin_g, k, k], wd.clone())), ("b", w(&[cout], bias.clone()))], - ); - let got = run(g, x.clone()).unwrap(); - let want = reference_conv(&x, &wd, [cout, cin_g, k, k], &bias, s, p, groups); - assert_eq!(got.shape, want.shape, "{cin} {cout} {groups} {k} {s} {p}"); - for (a, b) in got.data.iter().zip(&want.data) { - assert!((a - b).abs() < 1e-4, "{cin} {cout} {groups} {k} {s} {p}: {a} vs {b}"); - } - } - } - - #[test] - fn the_small_operators() { - let x = input(1, 4, 4, |i| i as f32 - 6.0); - // Relu, Sigmoid - let r = run(graph(vec![node("Relu", &["x"], &[])], vec![]), x.clone()).unwrap(); - assert!(r.data.iter().all(|v| *v >= 0.0) && r.data.get(7) == Some(&1.0)); - let s = run(graph(vec![node("Sigmoid", &["x"], &[])], vec![]), x.clone()).unwrap(); - assert!((s.data[6] - 0.5).abs() < 1e-6 && s.data.iter().all(|v| (0.0..=1.0).contains(v))); - // MaxPool 2x2/2 - let m = run( - graph(vec![node("MaxPool", &["x"], &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))])], vec![]), - x.clone(), - ) - .unwrap(); - assert_eq!((m.shape.clone(), m.data.clone()), (vec![1, 1, 2, 2], vec![-1.0, 1.0, 7.0, 9.0])); - // Resize x2 nearest - let z = run(graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 2.0, 2.0]))]), input(1, 2, 2, |i| i as f32)) - .unwrap(); - assert_eq!(z.shape, vec![1, 1, 4, 4]); - assert_eq!(&z.data[..8], &[0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]); - // Add - let a = run(graph(vec![node("Add", &["x", "x"], &[])], vec![]), x.clone()).unwrap(); - assert_eq!(a.data[5], 2.0 * x.data[5]); - // Transpose [0,2,3,1] then Reshape [1,-1,2] - let t = run(graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 2, 3, 1]))])], vec![]), input(2, 2, 3, |i| i as f32)).unwrap(); - assert_eq!(t.shape, vec![1, 2, 3, 2]); - assert_eq!(t.data, vec![0.0, 6.0, 1.0, 7.0, 2.0, 8.0, 3.0, 9.0, 4.0, 10.0, 5.0, 11.0]); - let rs = run( - graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![3], data: Data::I64(vec![1, -1, 2]) })]), - input(2, 2, 3, |i| i as f32), - ) - .unwrap(); - assert_eq!(rs.shape, vec![1, 6, 2]); - } - - /// An accelerator built on the slow reference convolution, optionally wrong or failing. - struct Fake { - skew: f32, - fail_after: std::sync::atomic::AtomicUsize, - } - impl Accelerator for Fake { - fn device(&self) -> String { - "test".into() - } - fn conv(&self, j: &ConvJob) -> std::result::Result>, String> { - if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 { - return Err("device lost".into()); - } - assert!(j.pt == j.pl && j.sh == j.sw); - let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap(); - let zero = vec![0.0; j.cout]; - let dims = [j.cout, j.cin / j.groups, j.kh, j.kw]; - let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups); - Ok(Some(out.data.iter().map(|v| v + self.skew).collect())) - } - } - - fn two_convs() -> (Graph, Tensor) { - let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))]; - let mut a = node("Conv", &["x", "w1", "b1"], &attrs); - a.outputs = vec!["h".into()]; - let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]); - let mut g = graph( - vec![a, b], - vec![ - ("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())), - ("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])), - ("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())), - ], - ); - g.inputs = vec![("x".into(), vec![1, 3, 9, 8])]; - (g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1)) - } - - #[test] - fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() { - let (g, x) = two_convs(); - let net = Net::new(g).unwrap(); - let cpu = net.run(x.clone()).unwrap(); - let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() }); - net.attach_checked(good, &x, 1e-4, false).unwrap(); - assert!(net.accelerator().is_some()); - assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape); - let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() }); - assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err()); - assert!(net.accelerator().is_none(), "a device that disagrees is not used"); - assert_eq!(net.run(x).unwrap(), cpu); - } - - #[test] - fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() { - let (g, x) = two_convs(); - let net = Net::new(g).unwrap(); - let cpu = net.run(x.clone()).unwrap(); - // agrees on the probe (two convs), then fails on the second conv of the next run - net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap(); - let out = net.run(x).unwrap(); - assert!(net.accelerator().is_none()); - for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) { - assert!((a - b).abs() < 1e-4); - } - } - - /// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls; - /// `poison` makes it give a value that is not finite. - struct ProgramFake { - calls: std::sync::atomic::AtomicUsize, - poison: bool, - } - - fn run_steps(p: &program::Program, input: &[f32]) -> Vec> { - use program::{Arg, Binary, Op, Unary}; - let mut values: Vec> = vec![Vec::new(); p.lens.len()]; - values[program::INPUT] = input.to_vec(); - for step in &p.steps { - let arg = |i: usize| -> Vec { - match &step.args[i] { - Arg::Value(v) => values[*v].clone(), - Arg::Const { data, .. } => data.to_vec(), - } - }; - let len = p.lens[step.out]; - let x = arg(0); - let out: Vec = match &step.op { - Op::Conv(j) => { - let (m, cg) = (j.cout / j.groups, j.cin / j.groups); - let mut out = vec![0f32; len]; - for co in 0..j.cout { - for oy in 0..j.oh { - for ox in 0..j.ow { - let mut v = j.bias.map_or(0.0, |b| b[co]); - for ci in 0..cg { - for ky in 0..j.kh { - for kx in 0..j.kw { - let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); - if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { - v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] - * x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; - } - } - } - } - out[(co * j.oh + oy) * j.ow + ox] = v; - } - } - } - out - } - Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(), - Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(), - Op::ChannelAffine { channels, plane } => { - let (s, o) = (arg(1), arg(2)); - x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect() - } - Op::ChannelPrelu { channels, plane } => { - let a = arg(1); - x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect() - } - Op::Binary { op, dims, a, b } => { - let y = arg(1); - (0..len) - .map(|i| { - let (mut rest, mut ia, mut ib) = (i, 0, 0); - for d in (0..4).rev() { - let at = rest % dims[d]; - rest /= dims[d]; - ia += at * a[d]; - ib += at * b[d]; - } - match op { - Binary::Add => x[ia] + y[ib], - Binary::Sub => x[ia] - y[ib], - Binary::Mul => x[ia] * y[ib], - } - }) - .collect() - } - Op::Transpose { dims, strides } => (0..len) - .map(|i| { - let (mut rest, mut from) = (i, 0); - for d in (0..4).rev() { - from += (rest % dims[d]) * strides[d]; - rest /= dims[d]; - } - x[from] - }) - .collect(), - Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len) - .map(|i| { - let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); - let mut m = f32::NEG_INFINITY; - for ky in 0..*kh { - for kx in 0..*kw { - m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]); - } - } - m - }) - .collect(), - Op::Resize { h, w, sh, sw, .. } => (0..len) - .map(|i| { - let (ow, oh) = (w * sw, h * sh); - let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); - x[(c * h + oy / sh) * w + ox / sw] - }) - .collect(), - Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::() / *plane as f32).collect(), - Op::Gemv { k, n, trans_b, alpha } => { - let (wt, bias) = (arg(1), arg(2)); - (0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::() + bias[o]).collect() - } - }; - assert_eq!(out.len(), len, "{:?}", step.op); - values[step.out] = out; - for f in &step.free { - values[*f] = Vec::new(); - } - } - p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect() - } - - impl Accelerator for ProgramFake { - fn device(&self) -> String { - "program test".into() - } - fn conv(&self, _: &ConvJob) -> std::result::Result>, String> { - Ok(None) - } - fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result>>>, String> { - self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let mut out: Vec>> = inputs.iter().map(|x| run_steps(p, x)).collect(); - if self.poison { - out[0][0][0] = f32::NAN; - } - Ok(Some(out)) - } - } - - fn every_operator_input(seed: usize) -> Tensor { - input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05) - } - - #[test] - fn a_program_of_every_operator_gives_what_the_interpreter_gives() { - let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); - let x = every_operator_input(0); - let cpu = net.run(x.clone()).unwrap(); - let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false }); - net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap(); - let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed); - assert!(calls > 0, "the whole network ran as one program"); - // several inputs go to the device together, and each gets its own outputs - let inputs: Vec = (0..3).map(every_operator_input).collect(); - let together = net.run_batch(inputs.clone()).unwrap(); - assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch"); - net.detach(); - for (x, got) in inputs.into_iter().zip(&together) { - let want = net.run(x).unwrap(); - agree(&want, got, 1e-5).unwrap(); - } - assert_eq!(cpu.len(), 2); - } - - #[test] - fn a_device_that_gives_values_that_are_not_finite_is_dropped() { - let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); - let x = every_operator_input(1); - let cpu = net.run(x.clone()).unwrap(); - assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err()); - assert!(net.accelerator().is_none()); - assert_eq!(net.run(x).unwrap(), cpu); - } - - #[test] - fn unsupported_and_inconsistent_models_are_errors() { - let x = input(1, 3, 3, |i| i as f32); - for (what, g) in [ - ("unknown op", graph(vec![node("Softmax", &["x"], &[])], vec![])), - ( - "dilated conv", - graph(vec![node("Conv", &["x", "w"], &[("dilations", Attr::Ints(vec![2, 2]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), - ), - ("conv without weights", graph(vec![node("Conv", &["x", "w"], &[])], vec![])), - ("channels do not match", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 5, 1, 1], vec![1.0; 5]))])), - ("kernel too big", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 1, 5, 5], vec![1.0; 25]))])), - ("zero group", graph(vec![node("Conv", &["x", "w"], &[("group", Attr::Int(0))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), - ("zero stride", graph(vec![node("Conv", &["x", "w"], &[("strides", Attr::Ints(vec![0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), - ( - "negative pad", - graph(vec![node("Conv", &["x", "w"], &[("pads", Attr::Ints(vec![-1, 0, 0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), - ), - ("bad perm", graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 0, 1, 2]))])], vec![])), - ( - "huge reshape", - graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![i64::MAX, 1]) })]), - ), - ("reshape mismatch", graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![4, 4]) })])), - ("non-integer resize", graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 1.5, 1.5]))])), - ("add that broadcasts", graph(vec![node("Add", &["x", "c"], &[])], vec![])), - ] { - assert!(run(g, x.clone()).is_err(), "{what}"); - } - } -} +mod tests; #[cfg(test)] mod recognition_tests; diff --git a/crates/faces/src/net/tests.rs b/crates/faces/src/net/tests.rs new file mode 100644 index 000000000..cd8dda7e5 --- /dev/null +++ b/crates/faces/src/net/tests.rs @@ -0,0 +1,382 @@ +use super::*; +use crate::graph::{Attr, Weight}; + +fn node(op: &str, inputs: &[&str], attrs: &[(&str, Attr)]) -> Node { + Node { + op: op.into(), + inputs: inputs.iter().map(|s| s.to_string()).collect(), + outputs: vec!["y".into()], + attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), + } +} +fn graph(nodes: Vec, weights: Vec<(&str, Weight)>) -> Graph { + Graph { + nodes, + weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(), + inputs: vec![("x".into(), vec![1, 1, 3, 3])], + outputs: vec!["y".into()], + } +} +fn w(dims: &[usize], v: Vec) -> Weight { + Weight { dims: dims.to_vec(), data: Data::F32(v) } +} +fn input(c: usize, h: usize, wd: usize, f: impl Fn(usize) -> f32) -> Tensor { + Tensor::new(vec![1, c, h, wd], (0..c * h * wd).map(f).collect()).unwrap() +} +fn run(g: Graph, x: Tensor) -> Result { + Ok(Net::new(g)?.run(x)?.remove(0).1) +} + +/// A direct, slow reference convolution to check the fast one against. +fn reference_conv(x: &Tensor, wd: &[f32], dims: [usize; 4], bias: &[f32], s: usize, p: usize, groups: usize) -> Tensor { + let (c, h, wi) = (x.shape[1], x.shape[2], x.shape[3]); + let [co_n, cin_g, kh, kw] = dims; + let (oh, ow) = ((h + 2 * p - kh) / s + 1, (wi + 2 * p - kw) / s + 1); + let mut out = vec![0.0f32; co_n * oh * ow]; + for co in 0..co_n { + let g = co / (co_n / groups); + for oy in 0..oh { + for ox in 0..ow { + let mut acc = bias[co]; + for cl in 0..cin_g { + for ky in 0..kh { + for kx in 0..kw { + let (iy, ix) = ((oy * s + ky) as isize - p as isize, (ox * s + kx) as isize - p as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < h && (ix as usize) < wi { + acc += x.data[((g * cin_g + cl) * h + iy as usize) * wi + ix as usize] * wd[((co * cin_g + cl) * kh + ky) * kw + kx]; + } + } + } + } + out[(co * oh + oy) * ow + ox] = acc; + } + } + } + let _ = c; + Tensor::new(vec![1, co_n, oh, ow], out).unwrap() +} + +#[test] +fn convolutions_match_a_slow_reference() { + // (cin, cout, groups, kernel, stride, pad, h, w) + for (cin, cout, groups, k, s, p, h, wi) in [ + (3, 4, 1, 3, 1, 1, 7, 9), + (3, 4, 1, 3, 2, 1, 8, 8), + (4, 4, 4, 3, 1, 1, 6, 5), + (2, 6, 1, 1, 1, 0, 5, 5), + (4, 8, 2, 3, 1, 1, 5, 6), + (1, 1, 1, 3, 1, 0, 3, 3), + ] { + let cin_g = cin / groups; + let wd: Vec = (0..cout * cin_g * k * k).map(|i| ((i * 37 % 11) as f32 - 5.0) * 0.1).collect(); + let bias: Vec = (0..cout).map(|i| i as f32 * 0.25 - 0.5).collect(); + let x = input(cin, h, wi, |i| ((i * 13 % 17) as f32 - 8.0) * 0.2); + let attrs = [("group", Attr::Int(groups as i64)), ("strides", Attr::Ints(vec![s as i64; 2])), ("pads", Attr::Ints(vec![p as i64; 4]))]; + let g = + graph(vec![node("Conv", &["x", "w", "b"], &attrs)], vec![("w", w(&[cout, cin_g, k, k], wd.clone())), ("b", w(&[cout], bias.clone()))]); + let got = run(g, x.clone()).unwrap(); + let want = reference_conv(&x, &wd, [cout, cin_g, k, k], &bias, s, p, groups); + assert_eq!(got.shape, want.shape, "{cin} {cout} {groups} {k} {s} {p}"); + for (a, b) in got.data.iter().zip(&want.data) { + assert!((a - b).abs() < 1e-4, "{cin} {cout} {groups} {k} {s} {p}: {a} vs {b}"); + } + } +} + +#[test] +fn the_small_operators() { + let x = input(1, 4, 4, |i| i as f32 - 6.0); + // Relu, Sigmoid + let r = run(graph(vec![node("Relu", &["x"], &[])], vec![]), x.clone()).unwrap(); + assert!(r.data.iter().all(|v| *v >= 0.0) && r.data.get(7) == Some(&1.0)); + let s = run(graph(vec![node("Sigmoid", &["x"], &[])], vec![]), x.clone()).unwrap(); + assert!((s.data[6] - 0.5).abs() < 1e-6 && s.data.iter().all(|v| (0.0..=1.0).contains(v))); + // MaxPool 2x2/2 + let m = run( + graph(vec![node("MaxPool", &["x"], &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))])], vec![]), + x.clone(), + ) + .unwrap(); + assert_eq!((m.shape.clone(), m.data.clone()), (vec![1, 1, 2, 2], vec![-1.0, 1.0, 7.0, 9.0])); + // Resize x2 nearest + let z = + run(graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 2.0, 2.0]))]), input(1, 2, 2, |i| i as f32)).unwrap(); + assert_eq!(z.shape, vec![1, 1, 4, 4]); + assert_eq!(&z.data[..8], &[0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]); + // Add + let a = run(graph(vec![node("Add", &["x", "x"], &[])], vec![]), x.clone()).unwrap(); + assert_eq!(a.data[5], 2.0 * x.data[5]); + // Transpose [0,2,3,1] then Reshape [1,-1,2] + let t = run(graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 2, 3, 1]))])], vec![]), input(2, 2, 3, |i| i as f32)).unwrap(); + assert_eq!(t.shape, vec![1, 2, 3, 2]); + assert_eq!(t.data, vec![0.0, 6.0, 1.0, 7.0, 2.0, 8.0, 3.0, 9.0, 4.0, 10.0, 5.0, 11.0]); + let rs = run( + graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![3], data: Data::I64(vec![1, -1, 2]) })]), + input(2, 2, 3, |i| i as f32), + ) + .unwrap(); + assert_eq!(rs.shape, vec![1, 6, 2]); +} + +/// An accelerator built on the slow reference convolution, optionally wrong or failing. +struct Fake { + skew: f32, + fail_after: std::sync::atomic::AtomicUsize, +} +impl Accelerator for Fake { + fn device(&self) -> String { + "test".into() + } + fn conv(&self, j: &ConvJob) -> std::result::Result>, String> { + if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 { + return Err("device lost".into()); + } + assert!(j.pt == j.pl && j.sh == j.sw); + let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap(); + let zero = vec![0.0; j.cout]; + let dims = [j.cout, j.cin / j.groups, j.kh, j.kw]; + let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups); + Ok(Some(out.data.iter().map(|v| v + self.skew).collect())) + } +} + +fn two_convs() -> (Graph, Tensor) { + let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))]; + let mut a = node("Conv", &["x", "w1", "b1"], &attrs); + a.outputs = vec!["h".into()]; + let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]); + let mut g = graph( + vec![a, b], + vec![ + ("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())), + ("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])), + ("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())), + ], + ); + g.inputs = vec![("x".into(), vec![1, 3, 9, 8])]; + (g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1)) +} + +#[test] +fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() }); + net.attach_checked(good, &x, 1e-4, false).unwrap(); + assert!(net.accelerator().is_some()); + assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape); + let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() }); + assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err()); + assert!(net.accelerator().is_none(), "a device that disagrees is not used"); + assert_eq!(net.run(x).unwrap(), cpu); +} + +#[test] +fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + // agrees on the probe (two convs), then fails on the second conv of the next run + net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap(); + let out = net.run(x).unwrap(); + assert!(net.accelerator().is_none()); + for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) { + assert!((a - b).abs() < 1e-4); + } +} + +/// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls; +/// `poison` makes it give a value that is not finite. +struct ProgramFake { + calls: std::sync::atomic::AtomicUsize, + poison: bool, +} + +fn run_steps(p: &program::Program, input: &[f32]) -> Vec> { + use program::{Arg, Binary, Op, Unary}; + let mut values: Vec> = vec![Vec::new(); p.lens.len()]; + values[program::INPUT] = input.to_vec(); + for step in &p.steps { + let arg = |i: usize| -> Vec { + match &step.args[i] { + Arg::Value(v) => values[*v].clone(), + Arg::Const { data, .. } => data.to_vec(), + } + }; + let len = p.lens[step.out]; + let x = arg(0); + let out: Vec = match &step.op { + Op::Conv(j) => { + let (m, cg) = (j.cout / j.groups, j.cin / j.groups); + let mut out = vec![0f32; len]; + for co in 0..j.cout { + for oy in 0..j.oh { + for ox in 0..j.ow { + let mut v = j.bias.map_or(0.0, |b| b[co]); + for ci in 0..cg { + for ky in 0..j.kh { + for kx in 0..j.kw { + let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { + v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] + * x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; + } + } + } + } + out[(co * j.oh + oy) * j.ow + ox] = v; + } + } + } + out + } + Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(), + Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(), + Op::ChannelAffine { channels, plane } => { + let (s, o) = (arg(1), arg(2)); + x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect() + } + Op::ChannelPrelu { channels, plane } => { + let a = arg(1); + x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect() + } + Op::Binary { op, dims, a, b } => { + let y = arg(1); + (0..len) + .map(|i| { + let (mut rest, mut ia, mut ib) = (i, 0, 0); + for d in (0..4).rev() { + let at = rest % dims[d]; + rest /= dims[d]; + ia += at * a[d]; + ib += at * b[d]; + } + match op { + Binary::Add => x[ia] + y[ib], + Binary::Sub => x[ia] - y[ib], + Binary::Mul => x[ia] * y[ib], + } + }) + .collect() + } + Op::Transpose { dims, strides } => (0..len) + .map(|i| { + let (mut rest, mut from) = (i, 0); + for d in (0..4).rev() { + from += (rest % dims[d]) * strides[d]; + rest /= dims[d]; + } + x[from] + }) + .collect(), + Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len) + .map(|i| { + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + let mut m = f32::NEG_INFINITY; + for ky in 0..*kh { + for kx in 0..*kw { + m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]); + } + } + m + }) + .collect(), + Op::Resize { h, w, sh, sw, .. } => (0..len) + .map(|i| { + let (ow, oh) = (w * sw, h * sh); + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + x[(c * h + oy / sh) * w + ox / sw] + }) + .collect(), + Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::() / *plane as f32).collect(), + Op::Gemv { k, n, trans_b, alpha } => { + let (wt, bias) = (arg(1), arg(2)); + (0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::() + bias[o]).collect() + } + }; + assert_eq!(out.len(), len, "{:?}", step.op); + values[step.out] = out; + for f in &step.free { + values[*f] = Vec::new(); + } + } + p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect() +} + +impl Accelerator for ProgramFake { + fn device(&self) -> String { + "program test".into() + } + fn conv(&self, _: &ConvJob) -> std::result::Result>, String> { + Ok(None) + } + fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result>>>, String> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut out: Vec>> = inputs.iter().map(|x| run_steps(p, x)).collect(); + if self.poison { + out[0][0][0] = f32::NAN; + } + Ok(Some(out)) + } +} + +fn every_operator_input(seed: usize) -> Tensor { + input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05) +} + +#[test] +fn a_program_of_every_operator_gives_what_the_interpreter_gives() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(0); + let cpu = net.run(x.clone()).unwrap(); + let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false }); + net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap(); + let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed); + assert!(calls > 0, "the whole network ran as one program"); + // several inputs go to the device together, and each gets its own outputs + let inputs: Vec = (0..3).map(every_operator_input).collect(); + let together = net.run_batch(inputs.clone()).unwrap(); + assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch"); + net.detach(); + for (x, got) in inputs.into_iter().zip(&together) { + let want = net.run(x).unwrap(); + agree(&want, got, 1e-5).unwrap(); + } + assert_eq!(cpu.len(), 2); +} + +#[test] +fn a_device_that_gives_values_that_are_not_finite_is_dropped() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(1); + let cpu = net.run(x.clone()).unwrap(); + assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err()); + assert!(net.accelerator().is_none()); + assert_eq!(net.run(x).unwrap(), cpu); +} + +#[test] +fn unsupported_and_inconsistent_models_are_errors() { + let x = input(1, 3, 3, |i| i as f32); + for (what, g) in [ + ("unknown op", graph(vec![node("Softmax", &["x"], &[])], vec![])), + ("dilated conv", graph(vec![node("Conv", &["x", "w"], &[("dilations", Attr::Ints(vec![2, 2]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), + ("conv without weights", graph(vec![node("Conv", &["x", "w"], &[])], vec![])), + ("channels do not match", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 5, 1, 1], vec![1.0; 5]))])), + ("kernel too big", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 1, 5, 5], vec![1.0; 25]))])), + ("zero group", graph(vec![node("Conv", &["x", "w"], &[("group", Attr::Int(0))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), + ("zero stride", graph(vec![node("Conv", &["x", "w"], &[("strides", Attr::Ints(vec![0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), + ( + "negative pad", + graph(vec![node("Conv", &["x", "w"], &[("pads", Attr::Ints(vec![-1, 0, 0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), + ), + ("bad perm", graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 0, 1, 2]))])], vec![])), + ("huge reshape", graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![i64::MAX, 1]) })])), + ("reshape mismatch", graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![4, 4]) })])), + ("non-integer resize", graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 1.5, 1.5]))])), + ("add that broadcasts", graph(vec![node("Add", &["x", "c"], &[])], vec![])), + ] { + assert!(run(g, x.clone()).is_err(), "{what}"); + } +} diff --git a/crates/geom/src/crop_drag.rs b/crates/geom/src/crop_drag.rs index 3e8f2484f..cdffa7cce 100644 --- a/crates/geom/src/crop_drag.rs +++ b/crates/geom/src/crop_drag.rs @@ -333,308 +333,4 @@ fn bisect(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 { } #[cfg(test)] -mod tests { - use super::*; - use CropHandle::*; - - const W: f64 = 600.0; - const H: f64 = 400.0; - const EPS: f64 = 1e-6; - - fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry { - CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 } - } - fn p(x: f64, y: f64) -> Point { - Point::new(x, y) - } - fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option) -> CropGeometry { - drag_crop(g, hd, from, to, W, H, aspect) - } - fn px_aspect(g: &CropGeometry) -> f64 { - g.rect.width() * W / (g.rect.height() * H) - } - fn near(a: f64, b: f64) -> bool { - (a - b).abs() < EPS - } - - // Feature: dragging a crop handle - // Scenario: a free corner drag inside the image - #[test] - fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None); - assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2)); - assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7)); - } - - // Scenario: dragging past the right edge (the weird behaviour of issue 295) - #[test] - fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None); - assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect); - assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect); - assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect); - } - - #[test] - fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() { - let a = 1.5; - let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a)); - assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect); - assert!(near(px_aspect(&g), a), "{}", px_aspect(&g)); - assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS); - assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect); - } - - #[test] - fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() { - let a = 2.0; - let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a); - let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a)); - assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays"); - assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways"); - assert!(near(px_aspect(&g), a)); - assert!(g.rect.height() > start.rect.height()); - } - - #[test] - fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None); - assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8)); - } - - #[test] - fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() { - let start = geo(0.5, 0.25, 0.9, 0.65); - let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None); - assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4)); - assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect); - assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect); - } - - #[test] - fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None); - assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0); - assert!(near(g.rect.x0, 0.2)); - } - - #[test] - fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() { - let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5)); - let c = start.rect.center(); - for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] { - for aspect in [None, Some(1.5)] { - let g = drag(start, hd, c, p(3.0, -3.0), aspect); - assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect); - assert_eq!(g.angle, 12.0); - } - } - } - - #[test] - fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() { - let s = geo(0.2, 0.2, 0.8, 0.8); - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s); - } - assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s); - assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s); - assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s); - } - - // Scenario: a locked edge drag when the frame already touches the image edge (review finding) - #[test] - fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() { - let a = 1.5; - let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H)); - let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a)); - assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect); - assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect); - assert!(near(px_aspect(&g), a)); - assert!(g.is_within_image(W, H)); - } - - #[test] - fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() { - let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5)); - let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5)); - assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5)); - } - - // Scenario: extreme or hostile magnitudes - #[test] - fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() { - let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H)); - let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0)); - assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect); - } - - #[test] - fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() { - let a = 1.5; - let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H)); - let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a)); - assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect); - let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None); - assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect); - } - - #[test] - fn given_an_unrotated_image_then_results_never_leave_the_unit_square() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None); - assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect); - } - - #[test] - fn handle_numbering_matches_the_overlay() { - assert_eq!(CropHandle::from_index(0), Some(TopLeft)); - assert_eq!(CropHandle::from_index(6), Some(Bottom)); - assert_eq!(CropHandle::from_index(8), Some(Move)); - assert_eq!(CropHandle::from_index(9), None); - assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft)); - assert_eq!(CropHandle::from_name("nope"), None); - } - - mod props { - use super::*; - use proptest::prelude::*; - - fn any_handle() -> impl Strategy { - (0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move)) - } - - proptest! { - /// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold. - #[test] - fn drag_is_always_inside_anchored_and_shaped( - angle in -45.0f64..45.0, - ratio in prop::option::of(0.3f64..4.0), - hd in any_handle(), - tx in -4.0f64..5.0, ty in -4.0f64..5.0, - shrink in 0.2f64..1.0, - ) { - let base = crate::crop_fit_angle(W, H, angle, ratio); - // a smaller start crop so there is room to move - let c = base.rect.center(); - let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle }; - prop_assume!(start.is_within_image(W, H)); - let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio); - prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect); - prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0); - let min = MIN_CROP_FRACTION * W.min(H) - 1e-6; - prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect); - if let Some(a) = ratio { - prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g)); - } - // anchors: edges opposite the handle do not move - let (s, r) = (start.rect, g.rect); - let keep_l = matches!(hd, TopRight | BottomRight | Right); - let keep_r = matches!(hd, TopLeft | BottomLeft | Left); - let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom); - let keep_b = matches!(hd, TopLeft | TopRight | Top); - { - if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); } - if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); } - if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); } - if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); } - } - if hd == Move { - prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9); - } - } - - #[test] - fn drag_never_panics_on_arbitrary_numbers( - x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0, - angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9, - ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(), - ) { - let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio); - prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite()); - } - } - } - - // Scenario: moving a frame that fills a straightened photo's height (issue #2299) - #[test] - fn given_largest_locked_frame_on_a_straightened_photo_when_moved_sideways_then_it_slides_along_the_tilt() { - for angle in [3.0, -10.0] { - let a = 2.0 / 3.0; - let start = crate::crop_fit_angle(W, H, angle, Some(a)); - let c = start.rect.center(); - let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); - assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect); - assert!(near(g.rect.width(), start.rect.width()) && near(g.rect.height(), start.rect.height()), "a move keeps the size"); - assert!(g.rect.x0 < start.rect.x0 - 0.2, "{angle}: stuck at {:?} (from {:?})", g.rect, start.rect); - let gc = g.rect.center(); - let further = drag(g, Move, gc, p(gc.x - 0.5, gc.y), Some(a)); - assert!((further.rect.x0 - g.rect.x0).abs() < 1e-4, "{angle}: not at the left side: {:?} → {:?}", g.rect, further.rect); - let back = drag(g, Move, c, p(c.x + 0.5, c.y), Some(a)); - assert!(back.rect.x1 > start.rect.x1 + 0.2, "{angle}: {:?}", back.rect); - } - } - - // Scenario: a straightened move that fits goes exactly where the pointer says - #[test] - fn given_straightened_photo_when_a_small_frame_is_moved_inside_then_it_follows_the_pointer() { - let start = CropGeometry { rect: Rect::new(0.4, 0.4, 0.5, 0.5), angle: 8.0 }; - let g = drag(start, Move, p(0.45, 0.45), p(0.5, 0.42), None); - assert!(near(g.rect.x0, 0.45) && near(g.rect.y0, 0.37), "{:?}", g.rect); - } - - // Scenario: a frame a handle drag left within the tolerance past a tilted edge (review of #2299) - #[test] - fn given_a_frame_just_past_a_tilted_edge_when_moved_then_it_still_slides_and_a_click_changes_nothing() { - let a = 2.0 / 3.0; - let fit = crate::crop_fit_angle(W, H, 3.0, Some(a)); - let k = 1.0 + 5e-7 / fit.rect.height(); - let start = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * k, fit.rect.height() * k), angle: 3.0 }; - assert!(start.is_within_image(W, H)); - let c = start.rect.center(); - let z = drag(start, Move, c, c, Some(a)).rect; - let s = start.rect; - assert!(near(z.x0, s.x0) && near(z.y0, s.y0) && near(z.x1, s.x1) && near(z.y1, s.y1), "a click inside the frame moves nothing: {z:?}"); - let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); - assert!(g.rect.x0 < start.rect.x0 - 0.2, "stuck at {:?}", g.rect); - assert!(g.is_within_image(W, H)); - } - - // Scenario: a frame a handle drag enlarged to a tilted edge (review of #2299) - #[test] - fn given_a_frame_enlarged_to_a_tilted_edge_by_a_handle_when_moved_then_it_still_slides() { - let a = 2.0 / 3.0; - for angle in [3.0, 6.0, -10.0, 12.0] { - let fit = crate::crop_fit_angle(W, H, angle, Some(a)); - let small = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * 0.6, fit.rect.height() * 0.6), angle }; - // the second fills the photo's height, at the tolerance's edge top and bottom - let shrunk = drag(fit, BottomRight, p(0.5, 0.5), p(0.35, 0.35), Some(a)); - for start in [small, shrunk] { - let big = drag(start, BottomRight, p(0.5, 0.5), p(1.5, 1.5), Some(a)); - let c = big.rect.center(); - let g = drag(big, Move, c, p(c.x - 0.5, c.y), Some(a)); - assert!(g.rect.x0 < big.rect.x0 - 0.2, "{angle}: stuck at {:?}", g.rect); - assert!( - g.is_within_image(W, H) - && (g.rect.width() / big.rect.width() - 1.0).abs() < 1e-5 - && (g.rect.height() / big.rect.height() - 1.0).abs() < 1e-5, - "{angle}: {:?}", - g.rect - ); - } - } - } - - // Scenario: a smaller frame pushed sideways past a tilted edge slides along it (#2299) - #[test] - fn given_a_small_frame_pushed_past_a_tilted_edge_then_it_stops_on_the_edge_and_slides_along_it() { - let start = CropGeometry { rect: Rect::new(0.35, 0.35, 0.55, 0.65), angle: 6.0 }; - let c = start.rect.center(); - let g = drag(start, Move, c, p(c.x - 0.2, c.y), None); - assert!(near(g.rect.x0, 0.15) && near(g.rect.y0, 0.35), "{:?}", g.rect); - let (a, b) = (drag(start, Move, c, p(c.x - 0.6, c.y), None), drag(start, Move, c, p(c.x - 1.0, c.y), None)); - for g in [a, b] { - assert!(g.is_within_image(W, H) && g.rect.x0 < 0.01, "{:?}", g.rect); - } - assert!(b.rect.y0 > a.rect.y0 + 0.01 && a.rect.y0 > start.rect.y0, "{:?} then {:?}", a.rect, b.rect); - } -} +mod tests; diff --git a/crates/geom/src/crop_drag/tests.rs b/crates/geom/src/crop_drag/tests.rs new file mode 100644 index 000000000..cf57c67cb --- /dev/null +++ b/crates/geom/src/crop_drag/tests.rs @@ -0,0 +1,303 @@ +use super::*; +use CropHandle::*; + +const W: f64 = 600.0; +const H: f64 = 400.0; +const EPS: f64 = 1e-6; + +fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry { + CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 } +} +fn p(x: f64, y: f64) -> Point { + Point::new(x, y) +} +fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option) -> CropGeometry { + drag_crop(g, hd, from, to, W, H, aspect) +} +fn px_aspect(g: &CropGeometry) -> f64 { + g.rect.width() * W / (g.rect.height() * H) +} +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < EPS +} + +// Feature: dragging a crop handle +// Scenario: a free corner drag inside the image +#[test] +fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2)); + assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7)); +} + +// Scenario: dragging past the right edge (the weird behaviour of issue 295) +#[test] +fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect); + assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect); + assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect); +} + +#[test] +fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() { + let a = 1.5; + let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a)); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect); + assert!(near(px_aspect(&g), a), "{}", px_aspect(&g)); + assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect); +} + +#[test] +fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() { + let a = 2.0; + let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a); + let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a)); + assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays"); + assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways"); + assert!(near(px_aspect(&g), a)); + assert!(g.rect.height() > start.rect.height()); +} + +#[test] +fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None); + assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8)); +} + +#[test] +fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() { + let start = geo(0.5, 0.25, 0.9, 0.65); + let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None); + assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4)); + assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect); + assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect); +} + +#[test] +fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None); + assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0); + assert!(near(g.rect.x0, 0.2)); +} + +#[test] +fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() { + let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5)); + let c = start.rect.center(); + for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] { + for aspect in [None, Some(1.5)] { + let g = drag(start, hd, c, p(3.0, -3.0), aspect); + assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect); + assert_eq!(g.angle, 12.0); + } + } +} + +#[test] +fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() { + let s = geo(0.2, 0.2, 0.8, 0.8); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s); + } + assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s); +} + +// Scenario: a locked edge drag when the frame already touches the image edge (review finding) +#[test] +fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() { + let a = 1.5; + let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H)); + let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a)); + assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect); + assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect); + assert!(near(px_aspect(&g), a)); + assert!(g.is_within_image(W, H)); +} + +#[test] +fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() { + let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5)); + let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5)); + assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5)); +} + +// Scenario: extreme or hostile magnitudes +#[test] +fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() { + let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H)); + let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0)); + assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect); +} + +#[test] +fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() { + let a = 1.5; + let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H)); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a)); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None); + assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect); +} + +#[test] +fn given_an_unrotated_image_then_results_never_leave_the_unit_square() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None); + assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect); +} + +#[test] +fn handle_numbering_matches_the_overlay() { + assert_eq!(CropHandle::from_index(0), Some(TopLeft)); + assert_eq!(CropHandle::from_index(6), Some(Bottom)); + assert_eq!(CropHandle::from_index(8), Some(Move)); + assert_eq!(CropHandle::from_index(9), None); + assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft)); + assert_eq!(CropHandle::from_name("nope"), None); +} + +mod props { + use super::*; + use proptest::prelude::*; + + fn any_handle() -> impl Strategy { + (0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move)) + } + + proptest! { + /// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold. + #[test] + fn drag_is_always_inside_anchored_and_shaped( + angle in -45.0f64..45.0, + ratio in prop::option::of(0.3f64..4.0), + hd in any_handle(), + tx in -4.0f64..5.0, ty in -4.0f64..5.0, + shrink in 0.2f64..1.0, + ) { + let base = crate::crop_fit_angle(W, H, angle, ratio); + // a smaller start crop so there is room to move + let c = base.rect.center(); + let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle }; + prop_assume!(start.is_within_image(W, H)); + let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio); + prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect); + prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0); + let min = MIN_CROP_FRACTION * W.min(H) - 1e-6; + prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect); + if let Some(a) = ratio { + prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g)); + } + // anchors: edges opposite the handle do not move + let (s, r) = (start.rect, g.rect); + let keep_l = matches!(hd, TopRight | BottomRight | Right); + let keep_r = matches!(hd, TopLeft | BottomLeft | Left); + let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom); + let keep_b = matches!(hd, TopLeft | TopRight | Top); + { + if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); } + if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); } + if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); } + if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); } + } + if hd == Move { + prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9); + } + } + + #[test] + fn drag_never_panics_on_arbitrary_numbers( + x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0, + angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9, + ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(), + ) { + let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio); + prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite()); + } + } +} + +// Scenario: moving a frame that fills a straightened photo's height (issue #2299) +#[test] +fn given_largest_locked_frame_on_a_straightened_photo_when_moved_sideways_then_it_slides_along_the_tilt() { + for angle in [3.0, -10.0] { + let a = 2.0 / 3.0; + let start = crate::crop_fit_angle(W, H, angle, Some(a)); + let c = start.rect.center(); + let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect); + assert!(near(g.rect.width(), start.rect.width()) && near(g.rect.height(), start.rect.height()), "a move keeps the size"); + assert!(g.rect.x0 < start.rect.x0 - 0.2, "{angle}: stuck at {:?} (from {:?})", g.rect, start.rect); + let gc = g.rect.center(); + let further = drag(g, Move, gc, p(gc.x - 0.5, gc.y), Some(a)); + assert!((further.rect.x0 - g.rect.x0).abs() < 1e-4, "{angle}: not at the left side: {:?} → {:?}", g.rect, further.rect); + let back = drag(g, Move, c, p(c.x + 0.5, c.y), Some(a)); + assert!(back.rect.x1 > start.rect.x1 + 0.2, "{angle}: {:?}", back.rect); + } +} + +// Scenario: a straightened move that fits goes exactly where the pointer says +#[test] +fn given_straightened_photo_when_a_small_frame_is_moved_inside_then_it_follows_the_pointer() { + let start = CropGeometry { rect: Rect::new(0.4, 0.4, 0.5, 0.5), angle: 8.0 }; + let g = drag(start, Move, p(0.45, 0.45), p(0.5, 0.42), None); + assert!(near(g.rect.x0, 0.45) && near(g.rect.y0, 0.37), "{:?}", g.rect); +} + +// Scenario: a frame a handle drag left within the tolerance past a tilted edge (review of #2299) +#[test] +fn given_a_frame_just_past_a_tilted_edge_when_moved_then_it_still_slides_and_a_click_changes_nothing() { + let a = 2.0 / 3.0; + let fit = crate::crop_fit_angle(W, H, 3.0, Some(a)); + let k = 1.0 + 5e-7 / fit.rect.height(); + let start = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * k, fit.rect.height() * k), angle: 3.0 }; + assert!(start.is_within_image(W, H)); + let c = start.rect.center(); + let z = drag(start, Move, c, c, Some(a)).rect; + let s = start.rect; + assert!(near(z.x0, s.x0) && near(z.y0, s.y0) && near(z.x1, s.x1) && near(z.y1, s.y1), "a click inside the frame moves nothing: {z:?}"); + let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.rect.x0 < start.rect.x0 - 0.2, "stuck at {:?}", g.rect); + assert!(g.is_within_image(W, H)); +} + +// Scenario: a frame a handle drag enlarged to a tilted edge (review of #2299) +#[test] +fn given_a_frame_enlarged_to_a_tilted_edge_by_a_handle_when_moved_then_it_still_slides() { + let a = 2.0 / 3.0; + for angle in [3.0, 6.0, -10.0, 12.0] { + let fit = crate::crop_fit_angle(W, H, angle, Some(a)); + let small = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * 0.6, fit.rect.height() * 0.6), angle }; + // the second fills the photo's height, at the tolerance's edge top and bottom + let shrunk = drag(fit, BottomRight, p(0.5, 0.5), p(0.35, 0.35), Some(a)); + for start in [small, shrunk] { + let big = drag(start, BottomRight, p(0.5, 0.5), p(1.5, 1.5), Some(a)); + let c = big.rect.center(); + let g = drag(big, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.rect.x0 < big.rect.x0 - 0.2, "{angle}: stuck at {:?}", g.rect); + assert!( + g.is_within_image(W, H) + && (g.rect.width() / big.rect.width() - 1.0).abs() < 1e-5 + && (g.rect.height() / big.rect.height() - 1.0).abs() < 1e-5, + "{angle}: {:?}", + g.rect + ); + } + } +} + +// Scenario: a smaller frame pushed sideways past a tilted edge slides along it (#2299) +#[test] +fn given_a_small_frame_pushed_past_a_tilted_edge_then_it_stops_on_the_edge_and_slides_along_it() { + let start = CropGeometry { rect: Rect::new(0.35, 0.35, 0.55, 0.65), angle: 6.0 }; + let c = start.rect.center(); + let g = drag(start, Move, c, p(c.x - 0.2, c.y), None); + assert!(near(g.rect.x0, 0.15) && near(g.rect.y0, 0.35), "{:?}", g.rect); + let (a, b) = (drag(start, Move, c, p(c.x - 0.6, c.y), None), drag(start, Move, c, p(c.x - 1.0, c.y), None)); + for g in [a, b] { + assert!(g.is_within_image(W, H) && g.rect.x0 < 0.01, "{:?}", g.rect); + } + assert!(b.rect.y0 > a.rect.y0 + 0.01 && a.rect.y0 > start.rect.y0, "{:?} then {:?}", a.rect, b.rect); +} diff --git a/crates/heif/src/census.rs b/crates/heif/src/census.rs new file mode 100644 index 000000000..6dbe035fc --- /dev/null +++ b/crates/heif/src/census.rs @@ -0,0 +1,122 @@ +//! Caps on the container's metadata boxes, counted before heic-rs touches the file. +//! +//! heic-rs 0.1.4 (pinned `=0.1.4`) has no aggregate work budget inside its container parsing: +//! every `ipma` association entry rescans the vector of already-seen item ids (quadratic in +//! the number of entries), and an `iloc` extent using construction method 2 reconstructs its +//! source item afresh for every extent that references it, with no shared cache. Legitimate +//! phone-photo metadata never comes near these caps — real corpora keep both well under a few +//! kilobytes — so anything above them is hostile or corrupt and is refused before the +//! countable CPU work happens. +//! +//! [`depth_boxes`] stops silently at structural blips: heic-rs's own parser reports those +//! better than this walk could, and an `ipma` box past the cap is caught wherever it parses. + +use heic_rs::boxes::BoxIter; + +use crate::Error; + +/// The largest `ipma` box (item ↔ property associations) payload accepted, 64 KiB. An entry +/// costs at least 3 bytes, so this bounds heic-rs's per-entry rescan of earlier item ids to +/// ~21 000 entries ≈ 220 million comparisons: long enough to be annoying, never minutes. +const MAX_IPMA_BYTES: usize = 64 << 10; + +/// The largest `iloc` box (item locations) payload accepted, 64 KiB. An extent costs at least +/// 12 bytes, so this bounds both the parse itself and how many extents a +/// construction-method-2 reconstruction chain can visit. +const MAX_ILOC_BYTES: usize = 64 << 10; + +/// Falls through unless the container declares more `ipma` or `iloc` payload than a +/// legitimate phone photo ever does. +pub fn cap(bytes: &[u8]) -> Result<(), Error> { + let mut c = Census::default(); + depth_boxes(&mut c, BoxIter::new(bytes), 0); + if c.ipma > MAX_IPMA_BYTES { + return Err(Error::Limit(format!("the image carries {} bytes of property associations, over the {MAX_IPMA_BYTES}-byte cap", c.ipma))); + } + if c.iloc > MAX_ILOC_BYTES { + return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc))); + } + Ok(()) +} + +/// The metadata-box payload sizes seen so far. +#[derive(Default)] +struct Census { + ipma: usize, + iloc: usize, +} + +/// Walks boxes, adding the payload sizes of those named in [`Census`]. +/// +/// Only `meta` and `iprp` (the two nesting boxes the metadata is built from) are descended +/// into, no deeper than that depth, so a nested-box trick can't hide a box from the count. +fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) { + if depth > 3 { + return; + } + for b in boxes { + // A structural error stops the walk; `heic_rs::probe` reports it properly afterwards. + let Ok(b) = b else { return }; + if b.is(b"ipma") { + c.ipma = c.ipma.saturating_add(b.payload.len()); + } else if b.is(b"iloc") { + c.iloc = c.iloc.saturating_add(b.payload.len()); + } else if b.is(b"meta") { + // `meta` is a FullBox: version and flags sit between its header and its children. + if b.payload.len() >= 4 { + depth_boxes(c, BoxIter::new(&b.payload[4..]), depth + 1); + } + } else if b.is(b"iprp") { + depth_boxes(c, b.children(), depth + 1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Assembles ftyp + meta(version/flags + iprp(ipco, ipma)) by hand; only the structure + /// the census walks matters, no item is ever defined. + fn file_with(ipma_payload: &[u8]) -> Vec { + let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec(); + let mut iprp = Vec::new(); + iprp.extend(box_of(b"ipco", b"")); + iprp.extend(box_of(b"ipma", ipma_payload)); + let mut meta = vec![0u8, 0, 0, 0]; // version 0, flags 0 + meta.extend(box_of(b"iprp", &iprp)); + out.extend(box_of(b"meta", &meta)); + out + } + + fn box_of(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(8 + body.len()); + let size = u32::try_from(8 + body.len()).expect("test box fits in u32"); + out.extend_from_slice(&size.to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out + } + + #[test] + fn an_oversized_ipma_is_refused() { + // 40 000 entries of 6 bytes: 240 KiB of ipma, far over the cap, and quadratic if + // parsed (each entry rescans the earlier item ids in heic-rs 0.1.4). + let mut ipma = Vec::new(); + for _ in 0..40_000u32 { + ipma.extend_from_slice(&[0u8, 0, 0, 0, 0, 0]); + } + let bytes = file_with(&ipma); + let r = crate::probe(&bytes).map(|_| ()); + assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}"); + } + + #[test] + fn a_undersized_ipma_is_untouched_by_the_caps() { + // The same shape with a normal ipma (two entries): the caps pass, and whatever error + // follows must not come from them. + let bytes = file_with(&[0, 0, 4, 1, 1, 2, 0, 0]); + let r = crate::probe(&bytes); + assert!(!matches!(r, Err(Error::Limit(ref m)) if m.contains("cap")), "{r:?}"); + } +} diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 21b9cf647..5b20e31d7 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -19,6 +19,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod census; mod orient; /// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`). #[cfg(any(test, feature = "testdata"))] @@ -149,20 +150,50 @@ pub fn probe(bytes: &[u8]) -> Result { } fn probe_unguarded(bytes: &[u8]) -> Result { - let info = heic_rs::probe(bytes).map_err(|e| match e { - // No `meta` box: no still image, only a `moov` image sequence (a video track). + census::cap(bytes)?; + // No `meta` box: no still image, only a `moov` image sequence (a video track). + let open_error = |e| match e { heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE), e => err(e), - })?; - let ctx = Context::open(bytes).map_err(err)?; + }; + let ctx = Context::open(bytes).map_err(open_error)?; let id = ctx.meta.primary; - let coded = match ctx.grid(id).map_err(err)? { + // heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other + // items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items + // multiplies at every level while each result stays small. Count those visits from the + // `iloc` table before reconstructing anything: the primary item first (`heic_rs::probe` and + // `Context::grid` both rebuild its descriptor), then its alpha plane, then every tile + // reference, repeats included. + let mut visits = 0_usize; + item_visits(&ctx, id, 0, &mut visits)?; + let info = heic_rs::probe(bytes).map_err(open_error)?; + if let Some(alpha) = ctx.alpha_item(id).map_err(err)? { + item_visits(&ctx, alpha, 0, &mut visits)?; + } + let mut coded = match ctx.grid(id).map_err(err)? { Some((_, tiles)) => tiles, None => vec![id], }; + for &item in coded.iter().filter(|&&item| item != id) { + item_visits(&ctx, item, 0, &mut visits)?; + } + // A `dimg` listing may name the same tile more than once, and reconstructable items are + // moreover rebuilt for every extent that references them in heic-rs 0.1.4. For the size + // check each unique item counts once: the total reconstruction (all of it assembled from + // `bytes` by heic-rs) is held below one copy of the file, above which a repeat or chain + // wants more bytes than exist. Order doesn't matter here: this only checks that every + // tile's bytes lie in the file. + coded.sort_unstable(); + coded.dedup(); + let mut reconstructed = 0_usize; for item in coded { - // Borrowed from `bytes` unless split across extents: cheap. - ctx.item_data(item).map_err(err)?; + reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len()); + if reconstructed > bytes.len() { + return Err(Error::Limit(format!( + "the container holds {reconstructed} bytes of item data, over the {} bytes of the file itself", + bytes.len() + ))); + } } Ok(Info { width: info.width, @@ -174,6 +205,36 @@ fn probe_unguarded(bytes: &[u8]) -> Result { }) } +/// Most extent visits heic-rs may spend reconstructing the primary image's coded items; real +/// files need one per tile (plus a few for the rare derived item). +const MAX_ITEM_VISITS: usize = 4096; +/// Deepest chain of items built from items we follow (heic-rs refuses beyond eight itself). +const MAX_ITEM_CHAIN: u32 = 8; + +/// Adds to `visits` the extents heic-rs would read to reconstruct `id`, following items built +/// from other items, and fails once that passes [`MAX_ITEM_VISITS`] or the chain is too deep. +fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Result<(), Error> { + use heic_rs::meta::iloc::Construction; + if depth > MAX_ITEM_CHAIN { + return Err(Error::Limit(format!("items built from items nest deeper than {MAX_ITEM_CHAIN} levels"))); + } + let Some(loc) = ctx.meta.location(id) else { return Ok(()) }; + *visits = visits.saturating_add(loc.extents.len().max(1)); + if *visits > MAX_ITEM_VISITS { + return Err(Error::Limit(format!("reconstructing the image takes over {MAX_ITEM_VISITS} item extent reads"))); + } + if loc.construction == Construction::Item { + for e in &loc.extents { + if let Ok(source) = u32::try_from(e.index) + && source != id + { + item_visits(ctx, source, depth + 1, visits)?; + } + } + } + Ok(()) +} + /// Decodes the primary image, with its metadata. pub fn decode(bytes: &[u8], options: &Options) -> Result { guarded(|| decode_unguarded(bytes, options)) @@ -188,7 +249,13 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul let ctx = Context::open(bytes).map_err(err)?; let primary = ctx.meta.primary; let mut best: Option<(u32, u32)> = None; + // A thumbnail whose own reconstruction would cost too much is not a candidate; the visits + // of all the candidates share one budget. + let mut candidate_visits = 0_usize; for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) { + if item_visits(&ctx, r.from, 0, &mut candidate_visits).is_err() { + continue; + } let Ok(p) = ctx.props(r.from) else { continue }; let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue }; let edge = w.max(h); @@ -197,6 +264,15 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul } } let Some((id, _)) = best else { return Ok(None) }; + // The same pre-scan as `probe`: the thumbnail's own descriptor and every tile reference + // are counted before heic-rs rebuilds any of them. + let mut visits = 0_usize; + item_visits(&ctx, id, 0, &mut visits)?; + if let Some((_, tiles)) = ctx.grid(id).map_err(err)? { + for &tile in &tiles { + item_visits(&ctx, tile, 0, &mut visits)?; + } + } let mut decoded = decode_item(&ctx, id, options, false)?; if decoded.icc.is_none() { decoded.icc = item_icc(&ctx, primary); @@ -232,6 +308,13 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result { /// `with_alpha` and the item has an alpha auxiliary image. fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result { let p = ctx.props(id).map_err(err)?; + // heic-rs 0.1.4 keeps every associated transform and its [`orient::apply`] allocates the + // full picture again for each: a file could chain the repetition and multiply decode CPU + // without changing any checked size. Real files carry at most one `clap`, `irot` and + // `imir`, so refuse anything approaching [`MAX_TRANSFORMS`] (CodeRabbit #44). + if p.transforms.len() > MAX_TRANSFORMS { + return Err(Error::Limit(format!("the image carries {} transform properties, over the {MAX_TRANSFORMS}-property cap", p.transforms.len()))); + } let limit = options.max_pixels; // The declared sizes are refused before anything is decoded or allocated. let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?; @@ -245,6 +328,14 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range }; let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None }; let (w, h) = planes.size(); + // A grid's canvas dimensions come from its payload, not the checked `ispe` (CodeRabbit + // #48): check the real one, before [`ycc::to_rgb16`] allocates for it. + match (u32::try_from(w), u32::try_from(h)) { + (Ok(wu), Ok(hu)) => { + heic_rs::image::check_pixels(wu, hu, limit).map_err(err)?; + } + _ => return Err(Error::Limit(format!("the image canvas is {w}x{h}, beyond the {limit}-pixel limit"))), + } let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest }; let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode) .ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?; @@ -281,6 +372,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) /// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1. const GRID_BUDGET: u64 = 2; +/// The most transform properties (`clap`/`irot`/`imir`) one item may carry. Real files have at +/// most one of each, so anything approaching this is a reordered or hostile association list. +const MAX_TRANSFORMS: usize = 12; + /// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. struct Coded<'a> { frames: Vec, @@ -370,8 +465,12 @@ fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour { /// metadata item never fails a decode whose pixels came out fine. fn metadata(ctx: &Context<'_>) -> (Option>, Option) { let id = ctx.meta.primary; - let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); - let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { + // Metadata is best effort, so an item that would cost too much to rebuild is skipped, not an error. + let mut visits = 0_usize; + let cheap = |item: Option, visits: &mut usize| item.is_none_or(|item| item_visits(ctx, item, 0, visits).is_ok()); + let exif = if cheap(ctx.exif_item(id), &mut visits) { ctx.exif(id).ok().flatten().map(<[u8]>::to_vec) } else { None }; + let xmp_item = ctx.xmp_item(id).filter(|&x| cheap(Some(x), &mut visits)); + let xmp = xmp_item.and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), Cow::Owned(b) => String::from_utf8(b).ok(), }); diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs index 3259dc96d..1286ff82a 100644 --- a/crates/heif/src/tests_synthetic.rs +++ b/crates/heif/src/tests_synthetic.rs @@ -185,3 +185,16 @@ fn truncated_and_corrupted_files_are_errors_not_panics() { let _ = decode_thumbnail(&b, 1, &Options::default()); } } + +/// CodeRabbit #44: heic-rs keeps every associated transform and each one re-allocates the +/// whole picture, so 13 of them are over the [`crate::MAX_TRANSFORMS`] cap and refused. +#[test] +fn too_many_transform_properties_are_refused() { + let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 13).collect(), ..Spec::new(32, 32, &grey) }); + let r = decode(&bytes, &Options::default()); + assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("transform")), "{r:?}"); + // At the cap they're not: 12 identity rotations decode normally. + let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 12).collect(), ..Spec::new(32, 32, &grey) }); + let d = decode(&bytes, &Options::default()).unwrap(); + assert_eq!((d.width, d.height), (32, 32)); +} diff --git a/crates/heif/src/ycc.rs b/crates/heif/src/ycc.rs index a0429b453..384d25f2a 100644 --- a/crates/heif/src/ycc.rs +++ b/crates/heif/src/ycc.rs @@ -161,6 +161,13 @@ pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampli if !(8..=16).contains(&bd) || w == 0 || h == 0 { return None; } + // The default caller limit is 2²8 pixels, at most four channels per pixel, so the largest + // legitimate `w·h·ch` is 2²30 samples. Anything more is a malformed or hostile canvas the + // decode path would have refused before calling here (CodeRabbit #48): refuse rather than + // ask for a multi-gigabuffer (or worse, over usize). + if w.checked_mul(h)?.checked_mul(ch)? > 1 << 30 { + return None; + } let max = ((1u32 << bd) - 1) as f32; let half = (1u32 << (bd - 1)) as f32; let scale = (1u32 << (bd - 8)) as f32; @@ -323,4 +330,23 @@ mod tests { .is_none() ); } + + /// CodeRabbit #48: a plane claiming 500 000 × 500 000 pixels must be refused without ever + /// allocating for it (before the cap this vec was never built; upper-bounding allocation + /// would have been a 7.5-gibabuffer or a panic instead of an error). + #[test] + fn an_oversized_canvas_is_refused_without_allocation() { + let f = Frame { + width: 500_000, + height: 500_000, + bit_depth: 8, + chroma: ChromaFormat::Yuv420, + y: vec![128], + cb: vec![128; 4], + cr: vec![128; 4], + y_stride: 0, + c_stride: 0, + }; + assert!(to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).is_none()); + } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index de086ca70..c00554fa8 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -4,7 +4,6 @@ use std::io::{BufRead, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -#[path = "progress.rs"] mod progress; use serde_json::{Value, json}; diff --git a/crates/mcp/src/progress.rs b/crates/mcp/src/server/progress.rs similarity index 99% rename from crates/mcp/src/progress.rs rename to crates/mcp/src/server/progress.rs index 3f6a8deec..455962266 100644 --- a/crates/mcp/src/progress.rs +++ b/crates/mcp/src/server/progress.rs @@ -222,7 +222,6 @@ impl Server { } #[cfg(test)] -#[path = "progress_tests.rs"] mod shutdown_tests; #[cfg(test)] diff --git a/crates/mcp/src/progress_tests.rs b/crates/mcp/src/server/progress/shutdown_tests.rs similarity index 100% rename from crates/mcp/src/progress_tests.rs rename to crates/mcp/src/server/progress/shutdown_tests.rs diff --git a/crates/mcp/tests/it/e2e.rs b/crates/mcp/tests/it/e2e.rs index 66cb93460..03d77c7e2 100644 --- a/crates/mcp/tests/it/e2e.rs +++ b/crates/mcp/tests/it/e2e.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; -use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode}; +use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode, call_tool}; use serde_json::{Value, json}; /// Feed a whole session through `Server::serve` and return the replies by id. @@ -184,6 +184,28 @@ fn remote_unreachable_is_a_tool_error() { assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable")); } +/// Eight distinct 1200×800 PNGs on disk, imported into a filesystem session with their real +/// metadata: a library of actual files for the batch export to read and develop. Generating the +/// procedural 6000×4000 demo library (and rendering from it) costs far more than this test's +/// contract — progress, cancellation and atomic 1200-edge PNG writes — needs. +fn file_backed_photos(dir: &std::path::Path) -> Headless { + let originals = dir.join("originals"); + std::fs::create_dir_all(&originals).unwrap(); + let mut paths = Vec::new(); + for i in 0..8usize { + let path = originals.join(format!("frame-{i:02}.png")); + let img = lightcraft_raster::Rgba8::from_fn(1200, 800, |x, y| { + [((x + i * 37) % 256) as u8, ((y * 3 + i * 11) % 256) as u8, ((x / 4 + y / 2 + i * 23) % 256) as u8, 255] + }); + lightcraft_mcp::write_image(&path, &img, 90).unwrap(); + paths.push(path.to_string_lossy().to_string()); + } + let mut headless = Headless::new(lightcraft_engine::Session::new().with_fs()); + let imported = call_tool(&mut headless, "import", &json!({"paths": paths})); + assert!(!imported.is_error, "{imported:?}"); + headless +} + /// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written: /// it keeps asking the server's progress hook at that photo boundary (which answers pings and /// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or @@ -236,7 +258,7 @@ fn export_progress_and_cancel() { 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: Headless::demo(), hold: hold.clone() }; + 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 || { @@ -250,8 +272,16 @@ fn export_progress_and_cancel() { 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 ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); - assert_eq!(ids.len(), 8); + let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap(); + 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(); + assert_eq!(names.len(), 8, "{names:?}"); + for p in photos { + 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(); 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(); @@ -314,10 +344,24 @@ fn export_progress_and_cancel() { 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 { - lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file"); + 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()); } - assert_eq!(std::fs::read_dir(dir.join("complete")).unwrap().count(), 8, "earlier export retained"); + // 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:?}"); + let mut exported = std::collections::HashSet::new(); + for path in &complete { + let bytes = std::fs::read(path).unwrap(); + assert_eq!(&bytes[1..4], b"PNG", "{}", path.display()); + let d = lightcraft_codecs::decode(&bytes, Default::default()).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + assert_eq!((d.width, d.height), (1200, 800), "{}", path.display()); + exported.insert(bytes); + } + assert_eq!(exported.len(), 8, "eight distinct outputs, not copies of one export"); 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. server.join().unwrap(); reader.join().unwrap(); std::fs::remove_dir_all(dir).unwrap(); diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index b5ccd4bb8..ca7f91821 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -34,6 +34,8 @@ pub use pano::{PanoOptions, PanoResult, Projection, stitch}; pub enum MergeError { #[error("{0} photo(s) selected; at least {1} are needed")] TooFew(usize, usize), + #[error("{0} photo(s) selected; a stitch of more than {1} is refused: matching every pair would take far too long")] + TooMany(usize, usize), #[error("{0}")] Mismatch(String), #[error("could not read a photo: {0}")] diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano.rs similarity index 97% rename from crates/merge/src/pano/mod.rs rename to crates/merge/src/pano.rs index 5ce439681..ba7e1c63f 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano.rs @@ -53,6 +53,11 @@ impl Default for PanoOptions { /// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down. pub const MAX_MEGAPIXELS: f64 = 512.0; +/// Most frames one stitch accepts: pair matching compares every pair of frames (n(n−1)/2 runs), so an +/// unbounded frame count is quadratic CPU as well as quadratic memory while the frames load. A panorama +/// with more than [`MAX_FRAMES`] shots is refused with an error, not attempted. +pub const MAX_FRAMES: usize = 256; + /// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down. pub const MAX_EDGE: f64 = 65_000.0; @@ -224,6 +229,9 @@ pub fn stitch(mut frames: Vec, opts: &PanoOptions, progress: &Progress) - if n < 2 { return Err(MergeError::TooFew(n, 2)); } + if n > MAX_FRAMES { + return Err(MergeError::TooMany(n, MAX_FRAMES)); + } check(progress, 0.01, "Finding features")?; let (cams, rms_before, rms) = register(&frames, progress)?; let used: Vec = (0..n).filter(|&i| cams[i].is_some()).collect(); diff --git a/crates/merge/src/pano/camera.rs b/crates/merge/src/pano/camera.rs index 7625170c3..4589f88db 100644 --- a/crates/merge/src/pano/camera.rs +++ b/crates/merge/src/pano/camera.rs @@ -61,10 +61,12 @@ pub struct PairMatch { /// Match every pair of images. `feats[k]` are in work coordinates scaled by `scales[k]`. pub fn match_pairs(feats: &[Features], scales: &[f64]) -> Vec { let n = feats.len(); - let pairs: Vec<(usize, usize)> = (0..n).flat_map(|i| (i + 1..n).map(move |j| (i, j))).collect(); - let mut out: Vec = pairs - .par_iter() - .filter_map(|&(i, j)| { + // the pairs are generated lazily: materialising all n(n−1)/2 up front is quadratic memory, including + // when every comparison comes up empty (no usable features) + let mut out: Vec = (0..n) + .into_par_iter() + .flat_map_iter(|i| ((i + 1)..n).map(move |j| (i, j))) + .filter_map(|(i, j)| { let m = features::match_features(&feats[i], &feats[j], 0.8); if m.len() < 12 { return None; diff --git a/crates/merge/tests/it/pano.rs b/crates/merge/tests/it/pano.rs index fdb736524..aeecde64c 100644 --- a/crates/merge/tests/it/pano.rs +++ b/crates/merge/tests/it/pano.rs @@ -246,6 +246,18 @@ fn unrelated_photos_do_not_stitch() { assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err()); } +/// Pair matching compares every pair of frames before anything is known about them: an unbounded +/// photo count is quadratic CPU and memory (issue #1519). More than `MAX_FRAMES` is refused at once, +/// before any frame is worked on. +#[test] +fn a_stitch_of_more_than_the_frame_cap_is_refused_up_front() { + let frames: Vec = (0..lightcraft_merge::pano::MAX_FRAMES + 1) + .map(|i| Frame::from_linear_rec2020(Rgb32f::from_fn(8, 8, |_, _| [(i % 255) as f32 / 255.0, 0.0, 0.0]), None)) + .collect(); + let err = stitch(frames, &PanoOptions::default(), &no_progress).unwrap_err(); + assert!(err.to_string().contains("refused"), "the frame cap errors, it does not begin the quadratic match: {err}"); +} + /// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their /// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down, /// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail. diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index f24bb5f2f..adcba0f80 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -1,7 +1,7 @@ //! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics). use crate::{DateTime, Flash, Gps, Metadata, Orientation}; -use lightcraft_tiff::{Ifd, Tiff, TiffError, tags as t}; +use lightcraft_tiff::{Ifd, ParseOptions, Tiff, TiffError, tags as t}; /// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks. pub fn strip_exif_header(b: &[u8]) -> &[u8] { @@ -16,8 +16,16 @@ pub fn read_exif(bytes: &[u8]) -> Metadata { /// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. pub fn try_read_exif(bytes: &[u8]) -> Result { + try_read_exif_with(bytes, &ParseOptions::default()) +} + +/// [`try_read_exif`] with the caller's parser options (its value allocation budget in particular). +/// A decoder working from a sparse buffer — only selected ranges of the logical file are actually +/// loaded, the rest is zeros — must not size its allocations by the logical file length, which +/// unrelated bytes of the file can inflate at no cost to the attacker. +pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result { let data = strip_exif_header(bytes); - let tiff = Tiff::parse(data)?; + let tiff = Tiff::parse_with(data, opts)?; let mut m = from_tiff(&tiff); // Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif) if m.lens_model.is_none() { diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs index e1c84f296..510782998 100644 --- a/crates/meta/src/focus.rs +++ b/crates/meta/src/focus.rs @@ -281,469 +281,4 @@ fn area_used_name(v: u8) -> Option<&'static str> { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; - - fn encipher(p: u8) -> u8 { - if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } - } - - fn entry(tag: u16, value: Value) -> Entry { - Entry { tag, value, offset: 0 } - } - - /// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. - fn alpha1() -> Ifd { - let mut block = vec![0u8; 0x40]; - block[0x16] = encipher(0x83); - block[0x17] = encipher(20); - Ifd { - entries: vec![ - entry(FOCUS_MODE, Value::Byte(vec![3])), - entry(AF_AREA_SETTING, Value::Byte(vec![0])), - entry(AF_TRACKING, Value::Byte(vec![1])), - entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), - entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), - entry(TAG_9402, Value::Undefined(block)), - ], - ..Default::default() - } - } - - fn set(ifd: &mut Ifd, tag: u16, value: Value) { - ifd.entries.retain(|e| e.tag != tag); - ifd.entries.push(entry(tag, value)); - // IFD entries are kept sorted by tag (lookups rely on it) - ifd.entries.sort_by_key(|e| e.tag); - } - - #[test] - fn the_cipher_matches_the_bytes_seen_in_files() { - // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values - assert_eq!(decipher(0x20), 20); - assert_eq!(decipher(0x77), 0x83); - assert_eq!(decipher(0xff), 0xff); - for p in 0..=255u8 { - assert_eq!(decipher(encipher(p)), p); - } - } - - #[test] - fn reads_the_alpha1_sample() { - let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); - assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); - assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); - assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); - let (cx, cy) = f.center(); - assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); - } - - #[test] - fn big_endian_frame_size() { - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); - let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); - assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); - } - - #[test] - fn follows_the_exif_orientation() { - // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); - let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); - let (cx, cy) = f.center(); - // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left - assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); - let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); - let (cx, cy) = f.center(); - assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); - } - - #[test] - fn no_real_point() { - // manual focus - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // the block says Manual (its focus mode agreeing) - let mut ifd = alpha1(); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(3); - block[0x17] = encipher(255); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // Multi at the exact centre - let mut ifd = alpha1(); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(3); - block[0x17] = encipher(0); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // …and without the block: Wide, tracking off - ifd.entries.retain(|e| e.tag != TAG_9402); - set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // missing location, zero frame, centre outside it - let mut ifd = alpha1(); - ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); - } - } - - #[test] - fn a_centred_spot_is_a_real_point() { - let mut ifd = alpha1(); - set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); - set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(2); - block[0x17] = encipher(3); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); - let (cx, cy) = f.center(); - assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); - } - - #[test] - fn a_block_that_disagrees_is_ignored() { - // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted - let mut ifd = alpha1(); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(7); - block[0x17] = encipher(255); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); - // a block too short to hold the bytes is ignored the same way - set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); - } - - #[test] - fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { - for fs in [ - Value::Undefined(vec![0, 0, 0, 0, 0, 0]), - Value::Undefined(vec![1]), - Value::Short(vec![5]), - Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), - ] { - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_FRAME_SIZE, fs); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); - } - // a frame reaching past the corner is clipped to the photo - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); - set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); - } - - #[test] - fn wrong_types_and_extreme_values_never_panic() { - let values = [ - Value::Ascii("x".into()), - Value::Byte(vec![]), - Value::SShort(vec![-1, -1, -1, -1]), - Value::Long(vec![u32::MAX; 4]), - Value::Double(vec![f64::NAN; 4]), - Value::Undefined(vec![0xff; 300]), - ]; - for v in &values { - for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { - let mut ifd = alpha1(); - set(&mut ifd, tag, v.clone()); - if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { - assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); - } - } - } - assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); - assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); - } - - /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. - fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { - sony_file_with(make, model, orientation, note_entries, None) - } - - /// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF - /// (the gap filled with "sample data") when `far` is given. - fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option) -> Vec { - let order = ByteOrder::Little; - // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, - // which we only know after writing; write once to learn where the note lands, then rebuild - let build = |note_at: u64, end: u64| -> (Vec, Vec) { - let mut note = b"SONY DSC \0\0\0".to_vec(); - let ifd_len = 2 + note_entries.len() * 12 + 4; - let mut tail: Vec = Vec::new(); - let mut outside: Vec = Vec::new(); - order.put_u16(&mut note, note_entries.len() as u16); - for (tag, v) in note_entries { - let raw = lightcraft_tiff::writer::encode(order, v); - order.put_u16(&mut note, *tag); - order.put_u16(&mut note, v.field_type() as u16); - order.put_u32(&mut note, v.count() as u32); - if raw.len() <= 4 { - let mut r = raw.clone(); - r.resize(4, 0); - note.extend(r); - } else if let (Some(gap), TAG_9402) = (far, *tag) { - order.put_u32(&mut note, (end + gap as u64) as u32); - outside = raw; - } else { - order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); - tail.extend(raw); - if tail.len() % 2 == 1 { - tail.push(0); - } - } - } - order.put_u32(&mut note, 0); - note.extend(tail); - let mut exif = IfdBuilder::new(); - exif.set(t::MAKER_NOTE, Value::Undefined(note)); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::MAKE, Value::Ascii(make.into())); - ifd0.set(t::MODEL, Value::Ascii(model.into())); - ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); - ifd0.set_child(t::EXIF_IFD, exif); - (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) - }; - let (first, _) = build(0, 0); - let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; - let (mut file, outside) = build(at, first.len() as u64); - assert_eq!(file.len(), first.len()); - assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); - if let Some(gap) = far { - file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); - file.extend(outside); - } - file - } - - fn alpha1_entries() -> Vec<(u16, Value)> { - alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() - } - - #[test] - fn read_exif_finds_it_through_the_maker_note_and_orientation() { - let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); - let m = crate::read_exif(&file); - let f = m.focus.expect("focus point"); - assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); - // EXIF 8: sensor x becomes 1 - upright y - let (cx, cy) = f.center(); - assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); - // an unconfirmed body or another maker: nothing - assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); - assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); - } - - #[test] - fn truncated_and_garbage_maker_notes_never_panic() { - let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); - let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; - for cut in (0..file.len()).step_by(7) { - let _ = crate::read_exif(&file[..cut]); - } - let mut seed = 0x2545_f491_u32; - for _ in 0..300 { - let mut f = file.clone(); - for _ in 0..8 { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - let i = note_at + (seed as usize % (file.len() - note_at)); - f[i] = (seed >> 8) as u8; - } - if let Some(fp) = crate::read_exif(&f).focus { - assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); - } - } - } - - /// A whole-file read and a header-only read of the same file agree, and the header read reads a - /// small part of it (the sample data after the metadata is never touched). - #[test] - fn header_read_matches_the_whole_file_and_reads_little() { - let read = |file: &[u8]| { - let mut asked = 0u64; - let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { - asked += buf.len() as u64; - let s = off as usize; - buf.copy_from_slice(&file[s..s + buf.len()]); - Ok(()) - }) - .unwrap(); - assert_eq!(r.bytes_read, asked); - r - }; - // a raw: the metadata, then 8 MB of "samples" - let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); - let meta_len = file.len(); - file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); - let whole = crate::extract(&file).focus; - assert!(whole.is_some()); - let r = read(&file); - assert_eq!(r.focus, whole); - assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); - assert!(meta_len < HEADER_PREFIX as usize); - // the area-mode block placed 3 MB further on: found in a further round, still a small part - let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); - far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); - let whole = crate::extract(&far).focus; - assert!(whole.is_some()); - let r = read(&far); - assert_eq!(r.focus, whole); - assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); - // a JPEG: the Exif segment after another segment - let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); - let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; - jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); - jpeg.extend_from_slice(&[0xff, 0xe1]); - jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); - jpeg.extend_from_slice(b"Exif\0\0"); - jpeg.extend_from_slice(&exif); - jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); - jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); - let whole = crate::extract(&jpeg).focus; - assert!(whole.is_some()); - let r = read(&jpeg); - assert_eq!(r.focus, whole); - assert!(r.bytes_read <= HEADER_PREFIX); - // unsupported bodies agree too (no point either way) - let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); - assert_eq!(read(&other).focus, None); - } - - /// Every truncation and corruption of a file gives the header read the whole-file read's answer - /// (or an error when the file is shorter than it said), never a panic. - #[test] - fn header_read_of_damaged_files_agrees_or_fails_cleanly() { - let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); - for cut in (0..file.len()).step_by(5) { - let part = &file[..cut]; - let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { - let s = off as usize; - buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); - Ok(()) - }) - .unwrap(); - assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); - } - let mut seed = 0x9e37_79b9_u32; - for _ in 0..400 { - let mut f = file.clone(); - for _ in 0..6 { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - let i = seed as usize % f.len(); - f[i] = (seed >> 8) as u8; - } - let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { - let s = off as usize; - buf.copy_from_slice(&f[s..s + buf.len()]); - Ok(()) - }) - .unwrap(); - assert_eq!(r.focus, crate::extract(&f).focus); - } - // a reader told the file is longer than it is (replaced since it was measured): an error - let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { - let s = off as usize; - buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); - Ok(()) - }); - assert!(r.is_err()); - } - - #[test] - fn the_synthetic_arw_is_laid_out_as_described() { - let f = synthetic_arw(3, 10); - assert_eq!(f.len(), 260); - let p = crate::extract(&f).focus.expect("a point"); - assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); - assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); - } - - #[test] - fn cameras_that_can_have_a_point() { - assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); - for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { - assert!(!camera_supported(c), "{c}"); - } - } - - /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) - /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative - /// to; skipped without it. No user file is part of the repository. - #[test] - fn real_sony_files_match_exiftool() { - let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; - let dir = std::path::Path::new(&dir); - type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; - // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) - let cases: [(&str, u16, Want); 8] = [ - // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking - ( - "2023/11-november/17th/_DSC6954.ARW", - 1, - Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), - ), - ( - "2023/11-november/17th/_DSC6894.ARW", - 1, - Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), - ), - // α1, manual focus (the location 4441 3060 is where the frame was parked) - ("2023/11-november/15th/_DSC6613.ARW", 1, None), - // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 - ("2022/01-january/9th/_DSC6079.ARW", 8, None), - // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking - ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), - // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking - ( - "2020/10-october/31st/DSC00065.ARW", - 8, - Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), - ), - // A7S III upside down, AF-C, a centred Flexible Spot: a real point - ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), - // A7S III, manual focus - ("2021/06-june/12th/DSC06806.ARW", 1, None), - ]; - for (name, exif_orientation, want) in cases { - let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); - let got = crate::extract(&bytes).focus; - match (want, got) { - (None, None) => {} - (Some((loc, size, words)), Some(f)) => { - let [w, h, x, y] = loc; - let sensor = - Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; - let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); - for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { - assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); - } - assert_eq!(f.words(), words, "{name}"); - } - (w, g) => panic!("{name}: want {w:?}, got {g:?}"), - } - } - } -} +mod tests; diff --git a/crates/meta/src/focus/tests.rs b/crates/meta/src/focus/tests.rs new file mode 100644 index 000000000..e5f79fbfe --- /dev/null +++ b/crates/meta/src/focus/tests.rs @@ -0,0 +1,449 @@ +use super::*; +use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + +fn encipher(p: u8) -> u8 { + if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } +} + +fn entry(tag: u16, value: Value) -> Entry { + Entry { tag, value, offset: 0 } +} + +/// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. +fn alpha1() -> Ifd { + let mut block = vec![0u8; 0x40]; + block[0x16] = encipher(0x83); + block[0x17] = encipher(20); + Ifd { + entries: vec![ + entry(FOCUS_MODE, Value::Byte(vec![3])), + entry(AF_AREA_SETTING, Value::Byte(vec![0])), + entry(AF_TRACKING, Value::Byte(vec![1])), + entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), + entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), + entry(TAG_9402, Value::Undefined(block)), + ], + ..Default::default() + } +} + +fn set(ifd: &mut Ifd, tag: u16, value: Value) { + ifd.entries.retain(|e| e.tag != tag); + ifd.entries.push(entry(tag, value)); + // IFD entries are kept sorted by tag (lookups rely on it) + ifd.entries.sort_by_key(|e| e.tag); +} + +#[test] +fn the_cipher_matches_the_bytes_seen_in_files() { + // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values + assert_eq!(decipher(0x20), 20); + assert_eq!(decipher(0x77), 0x83); + assert_eq!(decipher(0xff), 0xff); + for p in 0..=255u8 { + assert_eq!(decipher(encipher(p)), p); + } +} + +#[test] +fn reads_the_alpha1_sample() { + let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); + assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); + assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + let (cx, cy) = f.center(); + assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); +} + +#[test] +fn big_endian_frame_size() { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); + let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); + assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); +} + +#[test] +fn follows_the_exif_orientation() { + // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); + let (cx, cy) = f.center(); + // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left + assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); + let (cx, cy) = f.center(); + assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); +} + +#[test] +fn no_real_point() { + // manual focus + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // the block says Manual (its focus mode agreeing) + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // Multi at the exact centre + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(0); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // …and without the block: Wide, tracking off + ifd.entries.retain(|e| e.tag != TAG_9402); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // missing location, zero frame, centre outside it + let mut ifd = alpha1(); + ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); + } +} + +#[test] +fn a_centred_spot_is_a_real_point() { + let mut ifd = alpha1(); + set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(2); + block[0x17] = encipher(3); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); + let (cx, cy) = f.center(); + assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); +} + +#[test] +fn a_block_that_disagrees_is_ignored() { + // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(7); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); + // a block too short to hold the bytes is ignored the same way + set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); +} + +#[test] +fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { + for fs in + [Value::Undefined(vec![0, 0, 0, 0, 0, 0]), Value::Undefined(vec![1]), Value::Short(vec![5]), Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0])] + { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, fs); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); + } + // a frame reaching past the corner is clipped to the photo + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); +} + +#[test] +fn wrong_types_and_extreme_values_never_panic() { + let values = [ + Value::Ascii("x".into()), + Value::Byte(vec![]), + Value::SShort(vec![-1, -1, -1, -1]), + Value::Long(vec![u32::MAX; 4]), + Value::Double(vec![f64::NAN; 4]), + Value::Undefined(vec![0xff; 300]), + ]; + for v in &values { + for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { + let mut ifd = alpha1(); + set(&mut ifd, tag, v.clone()); + if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { + assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); + } + } + } + assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); +} + +/// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. +fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { + sony_file_with(make, model, orientation, note_entries, None) +} + +/// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF +/// (the gap filled with "sample data") when `far` is given. +fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option) -> Vec { + let order = ByteOrder::Little; + // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, + // which we only know after writing; write once to learn where the note lands, then rebuild + let build = |note_at: u64, end: u64| -> (Vec, Vec) { + let mut note = b"SONY DSC \0\0\0".to_vec(); + let ifd_len = 2 + note_entries.len() * 12 + 4; + let mut tail: Vec = Vec::new(); + let mut outside: Vec = Vec::new(); + order.put_u16(&mut note, note_entries.len() as u16); + for (tag, v) in note_entries { + let raw = lightcraft_tiff::writer::encode(order, v); + order.put_u16(&mut note, *tag); + order.put_u16(&mut note, v.field_type() as u16); + order.put_u32(&mut note, v.count() as u32); + if raw.len() <= 4 { + let mut r = raw.clone(); + r.resize(4, 0); + note.extend(r); + } else if let (Some(gap), TAG_9402) = (far, *tag) { + order.put_u32(&mut note, (end + gap as u64) as u32); + outside = raw; + } else { + order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); + tail.extend(raw); + if tail.len() % 2 == 1 { + tail.push(0); + } + } + } + order.put_u32(&mut note, 0); + note.extend(tail); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); + ifd0.set_child(t::EXIF_IFD, exif); + (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) + }; + let (first, _) = build(0, 0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; + let (mut file, outside) = build(at, first.len() as u64); + assert_eq!(file.len(), first.len()); + assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + if let Some(gap) = far { + file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); + file.extend(outside); + } + file +} + +fn alpha1_entries() -> Vec<(u16, Value)> { + alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() +} + +#[test] +fn read_exif_finds_it_through_the_maker_note_and_orientation() { + let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let m = crate::read_exif(&file); + let f = m.focus.expect("focus point"); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + // EXIF 8: sensor x becomes 1 - upright y + let (cx, cy) = f.center(); + assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); + // an unconfirmed body or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); +} + +#[test] +fn truncated_and_garbage_maker_notes_never_panic() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for cut in (0..file.len()).step_by(7) { + let _ = crate::read_exif(&file[..cut]); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..300 { + let mut f = file.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = note_at + (seed as usize % (file.len() - note_at)); + f[i] = (seed >> 8) as u8; + } + if let Some(fp) = crate::read_exif(&f).focus { + assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); + } + } +} + +/// A whole-file read and a header-only read of the same file agree, and the header read reads a +/// small part of it (the sample data after the metadata is never touched). +#[test] +fn header_read_matches_the_whole_file_and_reads_little() { + let read = |file: &[u8]| { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let s = off as usize; + buf.copy_from_slice(&file[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.bytes_read, asked); + r + }; + // a raw: the metadata, then 8 MB of "samples" + let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let meta_len = file.len(); + file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); + let whole = crate::extract(&file).focus; + assert!(whole.is_some()); + let r = read(&file); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); + assert!(meta_len < HEADER_PREFIX as usize); + // the area-mode block placed 3 MB further on: found in a further round, still a small part + let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); + far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); + let whole = crate::extract(&far).focus; + assert!(whole.is_some()); + let r = read(&far); + assert_eq!(r.focus, whole); + assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); + // a JPEG: the Exif segment after another segment + let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); + jpeg.extend_from_slice(&[0xff, 0xe1]); + jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpeg.extend_from_slice(b"Exif\0\0"); + jpeg.extend_from_slice(&exif); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); + let whole = crate::extract(&jpeg).focus; + assert!(whole.is_some()); + let r = read(&jpeg); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX); + // unsupported bodies agree too (no point either way) + let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + assert_eq!(read(&other).focus, None); +} + +/// Every truncation and corruption of a file gives the header read the whole-file read's answer +/// (or an error when the file is shorter than it said), never a panic. +#[test] +fn header_read_of_damaged_files_agrees_or_fails_cleanly() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + for cut in (0..file.len()).step_by(5) { + let part = &file[..cut]; + let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); + } + let mut seed = 0x9e37_79b9_u32; + for _ in 0..400 { + let mut f = file.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % f.len(); + f[i] = (seed >> 8) as u8; + } + let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(&f[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(&f).focus); + } + // a reader told the file is longer than it is (replaced since it was measured): an error + let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }); + assert!(r.is_err()); +} + +#[test] +fn the_synthetic_arw_is_laid_out_as_described() { + let f = synthetic_arw(3, 10); + assert_eq!(f.len(), 260); + let p = crate::extract(&f).focus.expect("a point"); + assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); +} + +#[test] +fn cameras_that_can_have_a_point() { + assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); + for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + assert!(!camera_supported(c), "{c}"); + } +} + +/// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) +/// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative +/// to; skipped without it. No user file is part of the repository. +#[test] +fn real_sony_files_match_exiftool() { + let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; + let dir = std::path::Path::new(&dir); + type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; + // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) + let cases: [(&str, u16, Want); 8] = [ + // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking + ("2023/11-november/17th/_DSC6954.ARW", 1, Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"]))), + ("2023/11-november/17th/_DSC6894.ARW", 1, Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"]))), + // α1, manual focus (the location 4441 3060 is where the frame was parked) + ("2023/11-november/15th/_DSC6613.ARW", 1, None), + // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 + ("2022/01-january/9th/_DSC6079.ARW", 8, None), + // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking + ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), + // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking + ("2020/10-october/31st/DSC00065.ARW", 8, Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"]))), + // A7S III upside down, AF-C, a centred Flexible Spot: a real point + ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), + // A7S III, manual focus + ("2021/06-june/12th/DSC06806.ARW", 1, None), + ]; + for (name, exif_orientation, want) in cases { + let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = crate::extract(&bytes).focus; + match (want, got) { + (None, None) => {} + (Some((loc, size, words)), Some(f)) => { + let [w, h, x, y] = loc; + let sensor = + Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; + let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); + for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { + assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); + } + assert_eq!(f.words(), words, "{name}"); + } + (w, g) => panic!("{name}: want {w:?}, got {g:?}"), + } + } +} diff --git a/crates/meta/src/focus_read.rs b/crates/meta/src/focus_read.rs index 5a5d349d1..65adbee75 100644 --- a/crates/meta/src/focus_read.rs +++ b/crates/meta/src/focus_read.rs @@ -171,7 +171,15 @@ fn tiff(r: &mut Reader, head: Vec) -> io::Result> { loaded.insert(0, head.len() as u64); for _ in 0..MAX_ROUNDS { match plan(&buf, &loaded) { - Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)), + Step::Ready => { + // The decoder's allocation budget follows the bytes actually fetched, not the logical + // file length: only the loaded ranges are data, and an unrelated entry that claims a + // huge value must be cut by what leaving it cost, not by what the buffer claims to hold. + // The same 8 × / + 1 MiB shape the general TIFF reader uses, over `reader.bytes` instead. + let opts = + lightcraft_tiff::ParseOptions { value_budget: Some(r.bytes.saturating_mul(8).saturating_add(1 << 20)), ..Default::default() }; + return Ok(crate::try_read_exif_with(&buf, &opts).ok().and_then(|m| m.focus)); + } Step::Nothing => return Ok(None), Step::Need(ranges) => { for (a, b) in ranges { diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index baebbfe0f..46c7a1043 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -27,7 +27,7 @@ mod xmp_merge; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; pub use datetime::DateTime; -pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; +pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif}; pub use focus::FocusPoint; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2dbf59482..8ca82da01 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -154,6 +154,11 @@ const MAX_NODES: usize = 200_000; // Expanding a long namespace URI on many short names must not amplify a small packet without bound. pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024; +/// Most text a converter's flattened property keys may add up to. Keys repeat their whole ancestor +/// path (`parent/field`), so a document with a long nested name and a wide child fan-out otherwise +/// retains its prefix once per property: node limits alone cannot bound that duplication. +const MAX_KEY_BYTES: usize = 1 << 20; + /// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque. pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result { canonical(qname, scopes, opaque_budget) @@ -332,18 +337,29 @@ fn is_rdf_meta_attr(k: &str) -> bool { matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns") } -fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>) { +fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { for (k, v) in &d.attrs { - if !is_rdf_meta_attr(k) { + if !is_rdf_meta_attr(k) && take_key_bytes(&format!("{prefix}{k}"), budget) { props.entry(format!("{prefix}{k}")).or_default().push(v.clone()); } } for c in &d.children { - collect_property(c, prefix, props); + collect_property(c, prefix, props, budget); } } -fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>) { +/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the +/// property is then left out, like other elements a limit has cut). +fn take_key_bytes(key: &str, budget: &mut usize) -> bool { + let n = key.len(); + if *budget < n { + return false; + } + *budget -= n; + true +} + +fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { let key = format!("{prefix}{}", p.name); if let Some(arr) = p.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) { let mut items: Vec<(bool, String)> = arr @@ -357,29 +373,36 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect(); if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() { for (k, v) in field_attrs { - props.entry(format!("{struct_prefix}{k}")).or_default().push(v.clone()); + let full = format!("{struct_prefix}{k}"); + if take_key_bytes(&full, budget) { + props.entry(full).or_default().push(v.clone()); + } } return; } let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string()); - props.entry(key).or_default().push(v); + if take_key_bytes(&key, budget) { + props.entry(key).or_default().push(v); + } } fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { @@ -497,8 +520,10 @@ pub fn parse_xmp(s: &str) -> Result { find_descriptions(&tree, &mut descs); let mut props = BTreeMap::new(); let mut values = BTreeMap::new(); + // One budget for the whole document: several descriptions cannot each spend a fresh one. + let mut key_budget = MAX_KEY_BYTES; for d in descs { - collect_description(d, "", &mut props); + collect_description(d, "", &mut props, &mut key_budget); if let XmpValue::Struct(m) = node_struct(d) { values.extend(m); } @@ -713,462 +738,4 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn full() -> Metadata { - Metadata { - // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the - // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. - regions: Vec::new(), - focus: None, - make: Some("Maker & Sons".into()), - model: Some("X <1>".into()), - serial_number: Some("SN1".into()), - software: Some("LightCraft".into()), - lens_make: Some("L".into()), - lens_model: Some("24-70mm".into()), - lens_serial_number: Some("LS".into()), - lens_spec: Some([24.0, 70.0, 2.8, 2.8]), - capture_time: DateTime::parse_iso("2022-05-06T07:08:09.123-04:00"), - exposure_time: Some(1.0 / 125.0), - f_number: Some(5.6), - iso: Some(800), - focal_length: Some(35.0), - focal_length_35mm: Some(52.0), - flash: Some(Flash { fired: true, raw: 0x59 }), - exposure_bias: Some(-1.0 / 3.0), - exposure_program: Some(2), - metering_mode: Some(3), - white_balance: Some(1), - orientation: Some(Orientation::Rotate270), - gps: Some(Gps { latitude: 48.858222, longitude: -2.2945, altitude: Some(-3.5) }), - width: Some(8000), - height: Some(6000), - artist: Some("Ann \"Photo\" Lee".into()), - copyright: Some("© 2022".into()), - copyright_marked: Some(true), - usage_terms: Some("Editorial use only; no ".into()), - copyright_url: Some("https://example.com/licence?a=1&b=2".into()), - title: Some("Tïtle".into()), - caption: Some("Line one\nline two".into()), - alt_text: Some("A tree by the sea".into()), - extended_description: Some("Long ".into()), - sublocation: Some("Champ de Mars".into()), - city: Some("Paris".into()), - state: Some("Île-de-France".into()), - country: Some("France".into()), - keywords: vec!["tree".into(), "sky & sea".into()], - hierarchical_keywords: vec!["Places|France|Paris".into()], - rating: Some(-1), - label: Some("Green".into()), - } - } - - #[test] - fn roundtrip_all_fields() { - let m = full(); - let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#; - let x = write_xmp(&m, Some(json)); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.lc_settings.as_deref(), Some(json)); - let mut got = d.metadata.clone(); - // floats go through rationals - assert!((got.exposure_bias.unwrap() + 1.0 / 3.0).abs() < 1e-5); - got.exposure_bias = m.exposure_bias; - let g = got.gps.unwrap(); - assert!((g.latitude - 48.858222).abs() < 1e-6 && (g.longitude + 2.2945).abs() < 1e-6, "{g:?}"); - got.gps = m.gps; - assert_eq!(got, m); - } - - #[test] - fn empty_metadata_roundtrip() { - let x = write_xmp(&Metadata::default(), None); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.metadata, Metadata::default()); - assert_eq!(d.lc_settings, None); - } - - #[test] - fn foreign_namespace_cannot_impersonate_known_metadata() { - for body in [ - r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, - r#">2foreign settings"#, - ] { - let packet = format!( - r#""# - ); - let d = parse_xmp(&packet).unwrap(); - assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); - assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); - assert!(!d.properties.contains_key("xmp:Rating")); - assert!(!d.values.contains_key("lc:settings")); - assert!(d.properties.values().any(|v| v == &["foreign settings"])); - assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); - } - } - - #[test] - fn foreign_namespace_names_are_distinct_and_scoped() { - let packet = r#" - - 1 - 2 - 4 - "#; - let d = parse_xmp(packet).unwrap(); - assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); - assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); - assert_eq!(d.values.len(), 3); - assert_eq!(d.properties["xmp:Rating"], vec!["4"]); - assert!(d.properties.values().any(|v| v == &["1"])); - assert!(d.properties.values().any(|v| v == &["2"])); - } - - #[test] - fn attribute_form_foreign_prefixes_and_structs() { - let x = r#" - - TitelTitle & more é - - k1k2 - "#; - let d = parse_xmp(x).unwrap(); - let m = d.metadata; - assert_eq!(m.rating, Some(4)); - assert_eq!(m.label.as_deref(), Some("Blue")); - assert_eq!(m.f_number, Some(4.0)); - assert_eq!(m.title.as_deref(), Some("Title & more é")); - assert_eq!(m.keywords, vec!["k1".to_string(), "k2".to_string()]); - assert_eq!(m.flash, Some(Flash { fired: false, raw: 0x10 })); - let g = m.gps.unwrap(); - assert!((g.latitude - 10.5).abs() < 1e-9); - assert!((g.longitude + 20.26).abs() < 1e-9); - assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); - } - - /// Copyright status, usage terms and info URL (XMP Rights Management schema): written as - /// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). - #[test] - fn rights_management_fields() { - let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; - let x = write_xmp(&m, None); - assert!(x.contains("False"), "{x}"); - assert!(x.contains("\n \n CC0"), "{x}"); - assert!(!x.contains("WebStatement"), "unset fields are left out"); - assert_eq!(parse_xmp(&x).unwrap().metadata, m); - // attribute form, other prefixes; anything but True / False is "unknown" - let read = |marked: &str| { - let x = format!( - r#" - - "# - ); - parse_xmp(&x).unwrap().metadata - }; - assert_eq!(read("True").copyright_marked, Some(true)); - assert_eq!(read("false").copyright_marked, Some(false)); - assert_eq!(read("").copyright_marked, None); - assert_eq!(read("maybe").copyright_marked, None); - assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); - } - - /// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and - /// must not hide a valid `exifEX:PhotographicSensitivity`. - #[test] - fn non_positive_iso_is_ignored() { - let read = |attrs: &str| { - let x = format!( - r#" - - "# - ); - parse_xmp(&x).unwrap().metadata.iso - }; - assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); - assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); - assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); - assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); - } - - #[test] - fn malformed_inputs() { - assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); - assert_eq!(parse_xmp("").unwrap().metadata, Metadata::default()); - assert_eq!(parse_xmp("not xml at all").unwrap().metadata, Metadata::default()); - let deep = "".repeat(1000); - assert_eq!(parse_xmp(&deep), Err(XmpError::TooComplex)); - let x = write_xmp(&full(), Some("{}")); - for n in (0..x.len()).step_by(7) { - if x.is_char_boundary(n) { - let _ = parse_xmp(&x[..n]); - } - } - } - - #[test] - fn lc_extras_and_crs_prefix() { - let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]); - assert!(!x.contains("xmlns:crs"), "we never write crs:"); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}")); - assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]); - assert!(!x.contains("bad name")); - let foreign = r#""#; - assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]); - } - - #[test] - fn gps_coords() { - assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); - assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); - assert_eq!(parse_gps_coord("45X"), None); - assert_eq!(parse_gps_coord(""), None); - assert_eq!(parse_gps_coord("é"), None); - assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); - assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); - assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); - } - - /// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. - #[test] - fn xmp_gps_out_of_range_rejected() { - let gps = |lat: &str, lon: &str| { - let x = format!( - r#""# - ); - parse_xmp(&x).unwrap().metadata.gps - }; - assert_eq!(gps("95,0N", "200,30E"), None); - assert_eq!(gps("95,0N", "10,0E"), None); - assert_eq!(gps("10,0S", "200,30W"), None); - assert_eq!(gps("90,0,1N", "0,0E"), None); - assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); - assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); - } - - /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how - /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one - /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas - /// (the default, and the only unit real files use) don't need it. - #[test] - fn mwg_regions_element_form() { - let x = r#" - - - - - - - - Jane Doe - Face - - - - - - - Pet - Rex - Good boy - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 3, "{regions:?}"); - - assert_eq!(regions[0].kind, RegionKind::Face); - assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); - assert_eq!(regions[0].description, None); - let r = regions[0].rect; - assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); - assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); - - // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. - assert_eq!(regions[1].kind, RegionKind::Other(String::new())); - assert_eq!(regions[1].name, None); - - assert_eq!(regions[2].kind, RegionKind::Pet); - assert_eq!(regions[2].name.as_deref(), Some("Rex")); - assert_eq!(regions[2].description.as_deref(), Some("Good boy")); - } - - /// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame - /// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the - /// upright frame. - #[test] - fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { - let x = r#" - - - - - - - - - - - - - - - - - - - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 5, "{regions:?}"); - let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); - assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); - assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); - assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); - assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); - assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); - } - - /// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an - /// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an - /// unrecognised `mwg-rs:Type` value kept verbatim. - #[test] - fn mwg_regions_attribute_form_and_unknown_type() { - let x = r#" - - - - - - - - - - - - - - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 2, "{regions:?}"); - assert_eq!(regions[0].kind, RegionKind::BarCode); - assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); - } - - /// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of - /// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area - /// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). - #[test] - fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { - let x = r#" - - - - - No Area - - - - - - Face - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); - assert_eq!(regions[0].kind, RegionKind::Face); - } - - /// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a - /// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view - /// would then draw or crop far outside the image. - #[test] - fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { - let x = r#" - - - - - Far right - Far left - Touching edge - Huge - Corner - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); - assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); - for r in ®ions { - let q = r.rect; - assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); - } - assert_eq!(regions[0].rect, Rect::UNIT); - let c = regions[1].rect; - assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); - } - - /// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). - #[test] - fn regions_merge_like_keywords() { - let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; - let mut a = Metadata::default(); - let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; - a.fill_missing(&b); - assert_eq!(a.regions, vec![with_region.clone()]); - - let mut a = Metadata::default(); - a.overlay_user_fields(&b); - assert_eq!(a.regions, vec![with_region]); - } - - #[test] - fn writer_drops_characters_xml_1_0_forbids() { - let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; - let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; - let x = write_xmp_lc(&m, &[("settings", bad)]); - let illegal: Vec = - x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); - assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); - assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); - } - - #[test] - fn numeric_references_to_forbidden_characters_are_not_decoded() { - assert_eq!(entity("#1"), None); - assert_eq!(entity("#0"), None); - assert_eq!(entity("#xFFFE"), None); - assert_eq!(entity("#x41"), Some('A')); - assert_eq!(entity("#9"), Some('\t')); - } -} +mod tests; diff --git a/crates/meta/src/xmp/tests.rs b/crates/meta/src/xmp/tests.rs new file mode 100644 index 000000000..3939d2753 --- /dev/null +++ b/crates/meta/src/xmp/tests.rs @@ -0,0 +1,485 @@ +use super::*; + +fn full() -> Metadata { + Metadata { + // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the + // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. + regions: Vec::new(), + focus: None, + make: Some("Maker & Sons".into()), + model: Some("X <1>".into()), + serial_number: Some("SN1".into()), + software: Some("LightCraft".into()), + lens_make: Some("L".into()), + lens_model: Some("24-70mm".into()), + lens_serial_number: Some("LS".into()), + lens_spec: Some([24.0, 70.0, 2.8, 2.8]), + capture_time: DateTime::parse_iso("2022-05-06T07:08:09.123-04:00"), + exposure_time: Some(1.0 / 125.0), + f_number: Some(5.6), + iso: Some(800), + focal_length: Some(35.0), + focal_length_35mm: Some(52.0), + flash: Some(Flash { fired: true, raw: 0x59 }), + exposure_bias: Some(-1.0 / 3.0), + exposure_program: Some(2), + metering_mode: Some(3), + white_balance: Some(1), + orientation: Some(Orientation::Rotate270), + gps: Some(Gps { latitude: 48.858222, longitude: -2.2945, altitude: Some(-3.5) }), + width: Some(8000), + height: Some(6000), + artist: Some("Ann \"Photo\" Lee".into()), + copyright: Some("© 2022".into()), + copyright_marked: Some(true), + usage_terms: Some("Editorial use only; no ".into()), + copyright_url: Some("https://example.com/licence?a=1&b=2".into()), + title: Some("Tïtle".into()), + caption: Some("Line one\nline two".into()), + alt_text: Some("A tree by the sea".into()), + extended_description: Some("Long ".into()), + sublocation: Some("Champ de Mars".into()), + city: Some("Paris".into()), + state: Some("Île-de-France".into()), + country: Some("France".into()), + keywords: vec!["tree".into(), "sky & sea".into()], + hierarchical_keywords: vec!["Places|France|Paris".into()], + rating: Some(-1), + label: Some("Green".into()), + } +} + +#[test] +fn roundtrip_all_fields() { + let m = full(); + let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#; + let x = write_xmp(&m, Some(json)); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.lc_settings.as_deref(), Some(json)); + let mut got = d.metadata.clone(); + // floats go through rationals + assert!((got.exposure_bias.unwrap() + 1.0 / 3.0).abs() < 1e-5); + got.exposure_bias = m.exposure_bias; + let g = got.gps.unwrap(); + assert!((g.latitude - 48.858222).abs() < 1e-6 && (g.longitude + 2.2945).abs() < 1e-6, "{g:?}"); + got.gps = m.gps; + assert_eq!(got, m); +} + +#[test] +fn empty_metadata_roundtrip() { + let x = write_xmp(&Metadata::default(), None); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.metadata, Metadata::default()); + assert_eq!(d.lc_settings, None); +} + +#[test] +fn foreign_namespace_cannot_impersonate_known_metadata() { + for body in [ + r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, + r#">2foreign settings"#, + ] { + let packet = format!( + r#""# + ); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); + assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); + assert!(!d.properties.contains_key("xmp:Rating")); + assert!(!d.values.contains_key("lc:settings")); + assert!(d.properties.values().any(|v| v == &["foreign settings"])); + assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); + } +} + +#[test] +fn foreign_namespace_names_are_distinct_and_scoped() { + let packet = r#" + + 1 + 2 + 4 + "#; + let d = parse_xmp(packet).unwrap(); + assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); + assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); + assert_eq!(d.values.len(), 3); + assert_eq!(d.properties["xmp:Rating"], vec!["4"]); + assert!(d.properties.values().any(|v| v == &["1"])); + assert!(d.properties.values().any(|v| v == &["2"])); +} + +#[test] +fn attribute_form_foreign_prefixes_and_structs() { + let x = r#" + + TitelTitle & more é + + k1k2 + "#; + let d = parse_xmp(x).unwrap(); + let m = d.metadata; + assert_eq!(m.rating, Some(4)); + assert_eq!(m.label.as_deref(), Some("Blue")); + assert_eq!(m.f_number, Some(4.0)); + assert_eq!(m.title.as_deref(), Some("Title & more é")); + assert_eq!(m.keywords, vec!["k1".to_string(), "k2".to_string()]); + assert_eq!(m.flash, Some(Flash { fired: false, raw: 0x10 })); + let g = m.gps.unwrap(); + assert!((g.latitude - 10.5).abs() < 1e-9); + assert!((g.longitude + 20.26).abs() < 1e-9); + assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); +} + +/// Copyright status, usage terms and info URL (XMP Rights Management schema): written as +/// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). +#[test] +fn rights_management_fields() { + let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; + let x = write_xmp(&m, None); + assert!(x.contains("False"), "{x}"); + assert!(x.contains("\n \n CC0"), "{x}"); + assert!(!x.contains("WebStatement"), "unset fields are left out"); + assert_eq!(parse_xmp(&x).unwrap().metadata, m); + // attribute form, other prefixes; anything but True / False is "unknown" + let read = |marked: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata + }; + assert_eq!(read("True").copyright_marked, Some(true)); + assert_eq!(read("false").copyright_marked, Some(false)); + assert_eq!(read("").copyright_marked, None); + assert_eq!(read("maybe").copyright_marked, None); + assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); +} + +/// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and +/// must not hide a valid `exifEX:PhotographicSensitivity`. +#[test] +fn non_positive_iso_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata.iso + }; + assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); + assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); +} + +#[test] +fn malformed_inputs() { + assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); + assert_eq!(parse_xmp("").unwrap().metadata, Metadata::default()); + assert_eq!(parse_xmp("not xml at all").unwrap().metadata, Metadata::default()); + let deep = "".repeat(1000); + assert_eq!(parse_xmp(&deep), Err(XmpError::TooComplex)); + let x = write_xmp(&full(), Some("{}")); + for n in (0..x.len()).step_by(7) { + if x.is_char_boundary(n) { + let _ = parse_xmp(&x[..n]); + } + } +} + +#[test] +fn lc_extras_and_crs_prefix() { + let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]); + assert!(!x.contains("xmlns:crs"), "we never write crs:"); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}")); + assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]); + assert!(!x.contains("bad name")); + let foreign = r#""#; + assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]); +} + +#[test] +fn gps_coords() { + assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); + assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); + assert_eq!(parse_gps_coord("45X"), None); + assert_eq!(parse_gps_coord(""), None); + assert_eq!(parse_gps_coord("é"), None); + assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); + assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); + assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); +} + +/// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. +#[test] +fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#""# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); +} + +/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how +/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one +/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas +/// (the default, and the only unit real files use) don't need it. +#[test] +fn mwg_regions_element_form() { + let x = r#" + + + + + + + + Jane Doe + Face + + + + + + + Pet + Rex + Good boy + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 3, "{regions:?}"); + + assert_eq!(regions[0].kind, RegionKind::Face); + assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); + assert_eq!(regions[0].description, None); + let r = regions[0].rect; + assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); + assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); + + // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. + assert_eq!(regions[1].kind, RegionKind::Other(String::new())); + assert_eq!(regions[1].name, None); + + assert_eq!(regions[2].kind, RegionKind::Pet); + assert_eq!(regions[2].name.as_deref(), Some("Rex")); + assert_eq!(regions[2].description.as_deref(), Some("Good boy")); +} + +/// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame +/// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the +/// upright frame. +#[test] +fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { + let x = r#" + + + + + + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); + assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); + assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); + assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); + assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); + assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); +} + +/// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an +/// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an +/// unrecognised `mwg-rs:Type` value kept verbatim. +#[test] +fn mwg_regions_attribute_form_and_unknown_type() { + let x = r#" + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 2, "{regions:?}"); + assert_eq!(regions[0].kind, RegionKind::BarCode); + assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); +} + +/// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of +/// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area +/// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). +#[test] +fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { + let x = r#" + + + + + No Area + + + + + + Face + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); + assert_eq!(regions[0].kind, RegionKind::Face); +} + +/// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a +/// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view +/// would then draw or crop far outside the image. +#[test] +fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { + let x = r#" + + + + + Far right + Far left + Touching edge + Huge + Corner + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); + for r in ®ions { + let q = r.rect; + assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); + } + assert_eq!(regions[0].rect, Rect::UNIT); + let c = regions[1].rect; + assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); +} + +/// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). +#[test] +fn regions_merge_like_keywords() { + let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; + let mut a = Metadata::default(); + let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; + a.fill_missing(&b); + assert_eq!(a.regions, vec![with_region.clone()]); + + let mut a = Metadata::default(); + a.overlay_user_fields(&b); + assert_eq!(a.regions, vec![with_region]); +} + +#[test] +fn writer_drops_characters_xml_1_0_forbids() { + let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; + let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; + let x = write_xmp_lc(&m, &[("settings", bad)]); + let illegal: Vec = + x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); + assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); + assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); +} + +#[test] +fn numeric_references_to_forbidden_characters_are_not_decoded() { + assert_eq!(entity("#1"), None); + assert_eq!(entity("#0"), None); + assert_eq!(entity("#xFFFE"), None); + assert_eq!(entity("#x41"), Some('A')); + assert_eq!(entity("#9"), Some('\t')); +} + +/// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child +/// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it +/// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text). +#[test] +fn flattened_keys_stay_within_the_cumulative_budget() { + let level = "n".repeat(200); + let mut doc = String::from( + "", + ); + for _ in 0..4 { + doc.push_str(&format!("")); + } + // each leaf has a name of its own, so each one adds a key + for i in 0..3000 { + doc.push_str(&format!("")); + } + doc.push_str(""); + for _ in 0..4 { + doc.push_str(&format!("")); + } + doc.push_str(""); + let d = parse_xmp(&doc).unwrap(); + let total: usize = d.properties.keys().map(String::len).sum(); + assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); + assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); + assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); +} diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 36cc29192..73383d1d4 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -738,457 +738,4 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, MaskComponent}; - - fn frame(w: usize, h: usize) -> Frame { - Frame::new(w, h, &DevelopSettings::default(), true) - } - - #[test] - fn linear_gradient_ramps() { - let f = frame(100, 50); - let img = Rgb32f::new(100, 50); - let l = Plane::new(100, 50); - let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0, None); - assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); - assert!((a.get(50, 25) - 0.5).abs() < 0.05); - } - - #[test] - fn stored_segmentations_render_at_any_size_and_follow_the_crop() { - // the left half selected, on a 16² logit grid - let side = 16; - let logits: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); - let seg = SegMask::from_logits(side, &logits); - for shape in [ - MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, - ] { - for (w, h) in [(40, 20), (400, 200)] { - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}"); - if w >= 400 { - let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count(); - assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px"); - } - } - } - // cropped to the right half: nothing left selected - let mut d = DevelopSettings::default(); - d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0); - let f = Frame::new(40, 20, &d, true); - let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; - let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0, None); - assert!(a.data.iter().all(|v| *v < 0.05)); - } - - #[test] - fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() { - // coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75) - let coarse = SegMask::from_logits(4, &[-12.0; 16]); - let side = 16; - let l: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); - let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]); - let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 }; - let (w, h) = (200, 100); - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask"); - assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half"); - assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half"); - } - - #[test] - fn a_hostile_number_of_detail_patches_is_capped() { - // built in memory (a deserialized document is capped already): only the first - // MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB - let side = 1024; - let l: Vec = vec![12.0; side * side]; - let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]); - let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 }; - let (w, h) = (64, 32); - let t = std::time::Instant::now(); - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01); - assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); - } - - #[test] - fn the_edge_setting_hardens_or_feathers_the_transition() { - let side = 16; - let l: Vec = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp - let seg = SegMask::from_logits(side, &l); - let (w, h) = (400, 40); - let soft_px = |edge: f64| { - let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge }; - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}"); - (0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count() - }; - let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0)); - assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); - } - - #[test] - fn ai_shapes_without_a_segmentation_select_nothing() { - let f = frame(30, 30); - let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30)); - for shape in [ - MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 }, - ] { - assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0, None).data.iter().all(|v| *v == 0.0)); - } - } - - #[test] - fn radial_inside_and_invert() { - let f = frame(100, 100); - let img = Rgb32f::new(100, 100); - let l = Plane::new(100, 100); - let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; - let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0, None); - assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); - let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() }; - let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0, None); - assert!(e[0].alpha.get(50, 50) < 0.01); - } - - /// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at - /// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once - /// (the committed one). - #[test] - fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { - let r = 12.0; - let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)]; - let mut live = Vec::new(); - let mut prev = None; - for q in pts { - if push_dabs(&mut live, prev, q, r) { - prev = Some(q); - } - } - assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len()); - assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9)); - assert_eq!(live.first(), pts.first()); - assert_eq!(live.last(), pts.last()); - // the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled) - let (w, h) = (1000, 500); - let f = frame(w, h); - let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() }; - let committed = brush_dabs(&stroke, &f, w, h); - let mut again = Vec::new(); - let mut prev = None; - for p in &stroke.points { - let q = f.norm_to_out(w, h).apply(*p); - if push_dabs(&mut again, prev, q, committed.r) { - prev = Some(q); - } - } - assert_eq!(committed.dabs, again); - assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); - // many huge segments stop at the stroke budget instead of adding up without bound - let mut big = Vec::new(); - let mut at = Point::new(0.0, 0.0); - for i in 0..5 { - let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); - if push_dabs(&mut big, Some(at), q, 1.0) { - at = q; - } - } - assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); - assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); - // a hostile segment places no in-between dabs - let mut v = Vec::new(); - assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); - assert!(!push_dabs(&mut v, None, Point::new(f64::INFINITY, 1.0), r)); - push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0); - assert!(v.len() <= 1_000_002, "{}", v.len()); - } - - /// A temporary output buffer must spend the same stroke budget as a retained one. - #[test] - fn incremental_dab_buffers_share_the_stroke_budget() { - let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; - let mut retained = Vec::new(); - let mut streamed = Vec::new(); - let mut retained_count = MAX_STROKE_DABS - 3; - let mut streamed_count = retained_count; - let mut prev = None; - for q in points { - let mut chunk = Vec::new(); - let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); - assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); - streamed.extend(chunk); - if accepted { - prev = Some(q); - } - } - assert_eq!(retained, streamed); - assert_eq!(retained.len(), 3); - assert_eq!(retained_count, MAX_STROKE_DABS); - assert_eq!(streamed_count, retained_count); - assert_eq!(prev, Some(points[1])); - assert!(!retained.contains(&points[2])); - assert!(!retained.contains(&points[3])); - } - - /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile - /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. - #[test] - fn pen_pressure_scales_dab_size_and_flow() { - let (w, h) = (400, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; - let alpha = |pressure: Vec| { - let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - let full = alpha(vec![]); - assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); - // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right - let ramp = alpha(vec![0.1, 1.0]); - let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); - assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); - assert!(width(&ramp, 350) + 2 >= width(&full, 350)); - assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); - let faint = alpha(vec![0.2]); - assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); - assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); - for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { - let a = alpha(hostile); - assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); - } - assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); - assert_eq!(pressure_radius(10.0, 0.0), 1.0); - assert_eq!(pressure_flow(0.8, 2.0), 0.8); - } - - #[test] - fn brush_and_subtract() { - let f = frame(200, 100); - let img = Rgb32f::new(200, 100); - let l = Plane::new(200, 100); - let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; - let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; - let m = Mask { - components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], - ..Default::default() - }; - let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0, None); - let a = &e[0].alpha; - assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50)); - assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); - assert!(a.get(40, 5) < 0.01); - } - - /// #518: holding the brush still adds no coverage. A press plus any number of stationary - /// pointer samples paints exactly the single dab of the press, whatever the frame rate. - #[test] - fn holding_the_brush_still_adds_no_coverage() { - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let p = Point::new(0.5, 0.5); - let spot = |samples: usize, flow: f64| { - let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - for flow in [100.0, 40.0] { - let press = spot(1, flow); - // the feathered edge is soft to begin with - let edge = press.get(100 + 14, 50); - assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}"); - for n in [2, 5, 60, 600] { - let held = spot(n, flow); - assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50)); - } - } - let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() }; - assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press"); - // a stroke that pauses partway lays the same dabs as one that doesn't - let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); - let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() }; - let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() }; - assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs); - } - - /// Flow builds up by passes: going back over an area in the same stroke adds coverage. - #[test] - fn a_second_pass_builds_up_with_low_flow() { - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); - let paint = |points: Vec| { - let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - let once = paint(vec![a, b]); - let twice = paint(vec![a, b, a]); - let thrice = paint(vec![a, b, a, b]); - let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50)); - assert!(o > 0.05 && o < 0.99, "one pass {o}"); - assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}"); - } - - #[test] - fn auto_mask_stops_at_edges() { - // dark left half, bright right half; a stroke along the boundary, centred on the dark side - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); - let l = img.map(crate::local::log_lum); - let stroke = |auto_mask| BrushStroke { - points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], - size: 0.08, - feather: 0.0, - auto_mask, - ..Default::default() - }; - let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; - let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0, None); - let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0, None); - // without Auto Mask the brush spills over the edge; with it, it stays on the dark side - assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); - assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); - assert!(auto.get(110, 50) < 0.02); - assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); - assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); - // on a flat area Auto Mask paints like the plain brush - let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); - let fl = flat.map(crate::local::log_lum); - let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0, None); - let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0, None); - assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); - } - - /// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge. - #[test] - fn refine_edges_follows_the_photo() { - let (w, h) = (120usize, 80usize); - // left half dark, right half bright - let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] }); - let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() }); - let f = Frame::new(w, h, &Default::default(), true); - let soft = Mask { - components: vec![MaskComponent { - name: None, - op: MaskOp::Add, - invert: false, - shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, - }], - ..Default::default() - }; - let refined = Mask { refine: 100.0, ..soft.clone() }; - let step = |m: &Mask| { - let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0, None); - a.data[40 * w + 63] - a.data[40 * w + 56] - }; - let (plain, sharp) = (step(&soft), step(&refined)); - assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); - } - - /// Issue #157: a colour-range sample of one flat patch must not select a clearly different - /// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), - /// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). - #[test] - fn color_range_keeps_to_the_sampled_colour() { - use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; - let (w, h) = (120usize, 40usize); - let m = SRGB.to_space(&REC2020); - let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); - let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); - let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); - let log_l = img.map(crate::local::log_lum); - let f = Frame::new(w, h, &Default::default(), true); - let mut sample = [0f64; 3]; - for y in 19..=21 { - for x in 29..=31 { - let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); - for k in 0..3 { - sample[k] += lab[k] as f64 / 9.0; - } - } - } - let alpha = |refine| { - let shape = MaskShape::ColorRange { samples: vec![sample], refine }; - let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0, None); - (a.get(30, 20), a.get(90, 20)) - }; - for refine in [0.0, 50.0, 85.0] { - let (l, r) = alpha(refine); - assert!(l > 0.95, "refine {refine}: sampled patch {l}"); - assert!(r < 0.05, "refine {refine}: other patch {r}"); - } - // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the - // other patch stays mostly out - let (l, r) = alpha(100.0); - assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); - } - - /// A half-resolution matte selecting the left `frac` of a `w × h` source. - fn left_matte(w: usize, h: usize, frac: f64) -> Image { - Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) - } - - /// The Sky mask follows the source's sky matte where it has one (here: the bottom-left of a - /// uniformly bright blue photo, where the heuristic sees no sky, and not its top right, where - /// it does), through user orientation, at any output size; without one it keeps the heuristic. - #[test] - fn sky_uses_the_sources_matte() { - let (w, h) = (200, 100); - let img = Rgb32f::filled(w, h, [0.5, 0.7, 1.4]); - let l = img.map(crate::local::log_lum); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Sky, left_matte(w, h, 0.5)); - let f = frame(w, h); - let heuristic = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, None); - let matte = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&mattes)); - assert!(heuristic.get(150, 5) > 0.9 && heuristic.get(25, 90) < 0.1, "{} {}", heuristic.get(150, 5), heuristic.get(25, 90)); - assert!(matte.get(150, 5) < 0.01 && matte.get(25, 90) > 0.99, "{} {}", matte.get(150, 5), matte.get(25, 90)); - // mattes of other kinds leave the heuristic alone - let mut hair = Mattes::default(); - hair.push(MatteKind::Hair, left_matte(w, h, 0.5)); - assert_eq!(shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&hair)), heuristic); - // rotated 90° clockwise, the source's left half is the top half; a preview and a larger - // render agree - let s = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; - let f = Frame::new(w, h, &s, true); - for (ow, oh) in [(100, 200), (25, 50)] { - let small = Rgb32f::filled(ow, oh, [0.5, 0.7, 1.4]); - let a = shape_alpha(&MaskShape::Sky, &f, ow, oh, &small, &small.map(crate::local::log_lum), 0.0, Some(&mattes)); - let at = |x: f64, y: f64| a.get((x * ow as f64) as usize, (y * oh as f64) as usize); - assert!(at(0.5, 0.2) > 0.99 && at(0.5, 0.8) < 0.01, "{ow}×{oh}: {} {}", at(0.5, 0.2), at(0.5, 0.8)); - } - } - - /// Subject, Background and People use person mattes: the whole person is the union of the - /// parts; a People part without a matte keeps the heuristic. - #[test] - fn people_and_subject_use_person_mattes() { - let (w, h) = (200, 100); - let img = Rgb32f::filled(w, h, [0.2; 3]); - let l = img.map(crate::local::log_lum); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Skin, left_matte(w, h, 0.25)); - let right: Image = Image::from_fn(w / 2, h / 2, |x, _| if x >= 75 { 255 } else { 0 }); - mattes.push(MatteKind::Hair, right); - let f = frame(w, h); - let eval = |shape: &MaskShape| shape_alpha(shape, &f, w, h, &img, &l, 0.0, Some(&mattes)); - let subject = eval(&MaskShape::Subject); - assert!(subject.get(10, 50) > 0.99 && subject.get(190, 50) > 0.99 && subject.get(100, 50) < 0.01); - let background = eval(&MaskShape::Background); - assert!(background.get(100, 50) > 0.99 && background.get(10, 50) < 0.01); - let hair = eval(&MaskShape::People { person: 0, parts: vec!["Hair".into()] }); - assert!(hair.get(190, 50) > 0.99 && hair.get(10, 50) < 0.01); - let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; - assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); - } -} +mod tests; diff --git a/crates/pipeline/src/masks/tests.rs b/crates/pipeline/src/masks/tests.rs new file mode 100644 index 000000000..a3f776d0d --- /dev/null +++ b/crates/pipeline/src/masks/tests.rs @@ -0,0 +1,452 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, MaskComponent}; + +fn frame(w: usize, h: usize) -> Frame { + Frame::new(w, h, &DevelopSettings::default(), true) +} + +#[test] +fn linear_gradient_ramps() { + let f = frame(100, 50); + let img = Rgb32f::new(100, 50); + let l = Plane::new(100, 50); + let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0, None); + assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); + assert!((a.get(50, 25) - 0.5).abs() < 0.05); +} + +#[test] +fn stored_segmentations_render_at_any_size_and_follow_the_crop() { + // the left half selected, on a 16² logit grid + let side = 16; + let logits: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); + let seg = SegMask::from_logits(side, &logits); + for shape in [ + MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + ] { + for (w, h) in [(40, 20), (400, 200)] { + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}"); + if w >= 400 { + let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count(); + assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px"); + } + } + } + // cropped to the right half: nothing left selected + let mut d = DevelopSettings::default(); + d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0); + let f = Frame::new(40, 20, &d, true); + let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0, None); + assert!(a.data.iter().all(|v| *v < 0.05)); +} + +#[test] +fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() { + // coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75) + let coarse = SegMask::from_logits(4, &[-12.0; 16]); + let side = 16; + let l: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); + let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]); + let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 }; + let (w, h) = (200, 100); + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask"); + assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half"); + assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half"); +} + +#[test] +fn a_hostile_number_of_detail_patches_is_capped() { + // built in memory (a deserialized document is capped already): only the first + // MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB + let side = 1024; + let l: Vec = vec![12.0; side * side]; + let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]); + let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 }; + let (w, h) = (64, 32); + let t = std::time::Instant::now(); + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); +} + +#[test] +fn the_edge_setting_hardens_or_feathers_the_transition() { + let side = 16; + let l: Vec = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp + let seg = SegMask::from_logits(side, &l); + let (w, h) = (400, 40); + let soft_px = |edge: f64| { + let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge }; + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}"); + (0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count() + }; + let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0)); + assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); +} + +#[test] +fn ai_shapes_without_a_segmentation_select_nothing() { + let f = frame(30, 30); + let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30)); + for shape in [ + MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 }, + ] { + assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0, None).data.iter().all(|v| *v == 0.0)); + } +} + +#[test] +fn radial_inside_and_invert() { + let f = frame(100, 100); + let img = Rgb32f::new(100, 100); + let l = Plane::new(100, 100); + let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; + let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0, None); + assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); + let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() }; + let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0, None); + assert!(e[0].alpha.get(50, 50) < 0.01); +} + +/// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at +/// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once +/// (the committed one). +#[test] +fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { + let r = 12.0; + let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)]; + let mut live = Vec::new(); + let mut prev = None; + for q in pts { + if push_dabs(&mut live, prev, q, r) { + prev = Some(q); + } + } + assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len()); + assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9)); + assert_eq!(live.first(), pts.first()); + assert_eq!(live.last(), pts.last()); + // the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled) + let (w, h) = (1000, 500); + let f = frame(w, h); + let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() }; + let committed = brush_dabs(&stroke, &f, w, h); + let mut again = Vec::new(); + let mut prev = None; + for p in &stroke.points { + let q = f.norm_to_out(w, h).apply(*p); + if push_dabs(&mut again, prev, q, committed.r) { + prev = Some(q); + } + } + assert_eq!(committed.dabs, again); + assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); + // many huge segments stop at the stroke budget instead of adding up without bound + let mut big = Vec::new(); + let mut at = Point::new(0.0, 0.0); + for i in 0..5 { + let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); + if push_dabs(&mut big, Some(at), q, 1.0) { + at = q; + } + } + assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); + assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); + // a hostile segment places no in-between dabs + let mut v = Vec::new(); + assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); + assert!(!push_dabs(&mut v, None, Point::new(f64::INFINITY, 1.0), r)); + push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0); + assert!(v.len() <= 1_000_002, "{}", v.len()); +} + +/// A temporary output buffer must spend the same stroke budget as a retained one. +#[test] +fn incremental_dab_buffers_share_the_stroke_budget() { + let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; + let mut retained = Vec::new(); + let mut streamed = Vec::new(); + let mut retained_count = MAX_STROKE_DABS - 3; + let mut streamed_count = retained_count; + let mut prev = None; + for q in points { + let mut chunk = Vec::new(); + let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); + assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); + streamed.extend(chunk); + if accepted { + prev = Some(q); + } + } + assert_eq!(retained, streamed); + assert_eq!(retained.len(), 3); + assert_eq!(retained_count, MAX_STROKE_DABS); + assert_eq!(streamed_count, retained_count); + assert_eq!(prev, Some(points[1])); + assert!(!retained.contains(&points[2])); + assert!(!retained.contains(&points[3])); +} + +/// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile +/// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. +#[test] +fn pen_pressure_scales_dab_size_and_flow() { + let (w, h) = (400, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; + let alpha = |pressure: Vec| { + let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let full = alpha(vec![]); + assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); + // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right + let ramp = alpha(vec![0.1, 1.0]); + let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); + assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); + assert!(width(&ramp, 350) + 2 >= width(&full, 350)); + assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); + let faint = alpha(vec![0.2]); + assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); + assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); + for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { + let a = alpha(hostile); + assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } + assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); + assert_eq!(pressure_radius(10.0, 0.0), 1.0); + assert_eq!(pressure_flow(0.8, 2.0), 0.8); +} + +#[test] +fn brush_and_subtract() { + let f = frame(200, 100); + let img = Rgb32f::new(200, 100); + let l = Plane::new(200, 100); + let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; + let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; + let m = Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], + ..Default::default() + }; + let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0, None); + let a = &e[0].alpha; + assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50)); + assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); + assert!(a.get(40, 5) < 0.01); +} + +/// #518: holding the brush still adds no coverage. A press plus any number of stationary +/// pointer samples paints exactly the single dab of the press, whatever the frame rate. +#[test] +fn holding_the_brush_still_adds_no_coverage() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let p = Point::new(0.5, 0.5); + let spot = |samples: usize, flow: f64| { + let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + for flow in [100.0, 40.0] { + let press = spot(1, flow); + // the feathered edge is soft to begin with + let edge = press.get(100 + 14, 50); + assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}"); + for n in [2, 5, 60, 600] { + let held = spot(n, flow); + assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50)); + } + } + let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() }; + assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press"); + // a stroke that pauses partway lays the same dabs as one that doesn't + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() }; + let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() }; + assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs); +} + +/// Flow builds up by passes: going back over an area in the same stroke adds coverage. +#[test] +fn a_second_pass_builds_up_with_low_flow() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paint = |points: Vec| { + let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let once = paint(vec![a, b]); + let twice = paint(vec![a, b, a]); + let thrice = paint(vec![a, b, a, b]); + let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50)); + assert!(o > 0.05 && o < 0.99, "one pass {o}"); + assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}"); +} + +#[test] +fn auto_mask_stops_at_edges() { + // dark left half, bright right half; a stroke along the boundary, centred on the dark side + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); + let l = img.map(crate::local::log_lum); + let stroke = |auto_mask| BrushStroke { + points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], + size: 0.08, + feather: 0.0, + auto_mask, + ..Default::default() + }; + let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; + let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0, None); + let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0, None); + // without Auto Mask the brush spills over the edge; with it, it stays on the dark side + assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); + assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); + assert!(auto.get(110, 50) < 0.02); + assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); + assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); + // on a flat area Auto Mask paints like the plain brush + let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); + let fl = flat.map(crate::local::log_lum); + let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0, None); + let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0, None); + assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); +} + +/// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge. +#[test] +fn refine_edges_follows_the_photo() { + let (w, h) = (120usize, 80usize); + // left half dark, right half bright + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] }); + let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() }); + let f = Frame::new(w, h, &Default::default(), true); + let soft = Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, + }], + ..Default::default() + }; + let refined = Mask { refine: 100.0, ..soft.clone() }; + let step = |m: &Mask| { + let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0, None); + a.data[40 * w + 63] - a.data[40 * w + 56] + }; + let (plain, sharp) = (step(&soft), step(&refined)); + assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); +} + +/// Issue #157: a colour-range sample of one flat patch must not select a clearly different +/// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), +/// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). +#[test] +fn color_range_keeps_to_the_sampled_colour() { + use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; + let (w, h) = (120usize, 40usize); + let m = SRGB.to_space(&REC2020); + let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); + let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); + let log_l = img.map(crate::local::log_lum); + let f = Frame::new(w, h, &Default::default(), true); + let mut sample = [0f64; 3]; + for y in 19..=21 { + for x in 29..=31 { + let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); + for k in 0..3 { + sample[k] += lab[k] as f64 / 9.0; + } + } + } + let alpha = |refine| { + let shape = MaskShape::ColorRange { samples: vec![sample], refine }; + let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0, None); + (a.get(30, 20), a.get(90, 20)) + }; + for refine in [0.0, 50.0, 85.0] { + let (l, r) = alpha(refine); + assert!(l > 0.95, "refine {refine}: sampled patch {l}"); + assert!(r < 0.05, "refine {refine}: other patch {r}"); + } + // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the + // other patch stays mostly out + let (l, r) = alpha(100.0); + assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); +} + +/// A half-resolution matte selecting the left `frac` of a `w × h` source. +fn left_matte(w: usize, h: usize, frac: f64) -> Image { + Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) +} + +/// The Sky mask follows the source's sky matte where it has one (here: the bottom-left of a +/// uniformly bright blue photo, where the heuristic sees no sky, and not its top right, where +/// it does), through user orientation, at any output size; without one it keeps the heuristic. +#[test] +fn sky_uses_the_sources_matte() { + let (w, h) = (200, 100); + let img = Rgb32f::filled(w, h, [0.5, 0.7, 1.4]); + let l = img.map(crate::local::log_lum); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, left_matte(w, h, 0.5)); + let f = frame(w, h); + let heuristic = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, None); + let matte = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&mattes)); + assert!(heuristic.get(150, 5) > 0.9 && heuristic.get(25, 90) < 0.1, "{} {}", heuristic.get(150, 5), heuristic.get(25, 90)); + assert!(matte.get(150, 5) < 0.01 && matte.get(25, 90) > 0.99, "{} {}", matte.get(150, 5), matte.get(25, 90)); + // mattes of other kinds leave the heuristic alone + let mut hair = Mattes::default(); + hair.push(MatteKind::Hair, left_matte(w, h, 0.5)); + assert_eq!(shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&hair)), heuristic); + // rotated 90° clockwise, the source's left half is the top half; a preview and a larger + // render agree + let s = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; + let f = Frame::new(w, h, &s, true); + for (ow, oh) in [(100, 200), (25, 50)] { + let small = Rgb32f::filled(ow, oh, [0.5, 0.7, 1.4]); + let a = shape_alpha(&MaskShape::Sky, &f, ow, oh, &small, &small.map(crate::local::log_lum), 0.0, Some(&mattes)); + let at = |x: f64, y: f64| a.get((x * ow as f64) as usize, (y * oh as f64) as usize); + assert!(at(0.5, 0.2) > 0.99 && at(0.5, 0.8) < 0.01, "{ow}×{oh}: {} {}", at(0.5, 0.2), at(0.5, 0.8)); + } +} + +/// Subject, Background and People use person mattes: the whole person is the union of the +/// parts; a People part without a matte keeps the heuristic. +#[test] +fn people_and_subject_use_person_mattes() { + let (w, h) = (200, 100); + let img = Rgb32f::filled(w, h, [0.2; 3]); + let l = img.map(crate::local::log_lum); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Skin, left_matte(w, h, 0.25)); + let right: Image = Image::from_fn(w / 2, h / 2, |x, _| if x >= 75 { 255 } else { 0 }); + mattes.push(MatteKind::Hair, right); + let f = frame(w, h); + let eval = |shape: &MaskShape| shape_alpha(shape, &f, w, h, &img, &l, 0.0, Some(&mattes)); + let subject = eval(&MaskShape::Subject); + assert!(subject.get(10, 50) > 0.99 && subject.get(190, 50) > 0.99 && subject.get(100, 50) < 0.01); + let background = eval(&MaskShape::Background); + assert!(background.get(100, 50) > 0.99 && background.get(10, 50) < 0.01); + let hair = eval(&MaskShape::People { person: 0, parts: vec!["Hair".into()] }); + assert!(hair.get(190, 50) > 0.99 && hair.get(10, 50) < 0.01); + let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; + assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); +} diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index 31e8d1351..b0d7cc7f9 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -650,488 +650,4 @@ fn min_filter(p: &Plane, r: usize, max: bool) -> Plane { } #[cfg(test)] -mod tests { - use super::*; - - // An empty image (no pixel to sample) has a fingerprint, its dimensions' (indexing its first pixel panicked) - #[test] - fn an_empty_image_has_a_fingerprint() { - assert_eq!(fingerprint(&Rgb32f::new(0, 0)), fingerprint(&Rgb32f::new(0, 0))); - assert_ne!(fingerprint(&Rgb32f::new(0, 3)), fingerprint(&Rgb32f::new(3, 0))); - } - - /// splitmix64: deterministic pseudo-random numbers for the warp tests. - struct Rng(u64); - - impl Rng { - fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - /// Uniform in `lo..hi`. - fn range(&mut self, lo: f64, hi: f64) -> f64 { - lo + (hi - lo) * ((self.next() >> 11) as f64 / (1u64 << 53) as f64) - } - /// `range(lo, hi)` or, one time in four, exactly 0 (to take the "no correction" branches). - fn maybe(&mut self, lo: f64, hi: f64) -> f64 { - if self.next().is_multiple_of(4) { 0.0 } else { self.range(lo, hi) } - } - } - - /// A pseudo-random warp: perspective, manual distortion, CA and an embedded lens warp, each sometimes off. - /// Sizes are Pythagorean (exact `hypot`), so results don't depend on the platform's libm. - fn random_warp(r: &mut Rng) -> Warp { - const SIZES: [(f64, f64); 6] = [(4000.0, 3000.0), (3000.0, 4000.0), (1200.0, 900.0), (1200.0, 500.0), (56.0, 33.0), (6000.0, 4500.0)]; - let (w, h) = SIZES[(r.next() % SIZES.len() as u64) as usize]; - let mut wp = Warp::identity(w, h); - if !r.next().is_multiple_of(4) { - let m = [ - 1.0 + r.range(-0.2, 0.2), - r.range(-0.2, 0.2), - r.range(-0.1, 0.1), - r.range(-0.2, 0.2), - 1.0 + r.range(-0.2, 0.2), - r.range(-0.1, 0.1), - r.range(-0.4, 0.4), - r.range(-0.4, 0.4), - 1.0 + r.range(-0.1, 0.1), - ]; - wp.persp_inv = Homography(m); - wp.persp = wp.persp_inv.inverse().unwrap_or(Homography::IDENTITY); - } - wp.k1 = r.maybe(-0.2, 0.2); - wp.ca = [r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003)]; - if !r.next().is_multiple_of(4) { - let mut plane = || { - [ - 1.0 + r.range(-0.05, 0.05), - r.range(-0.08, 0.08), - r.range(-0.05, 0.05), - r.range(-0.02, 0.02), - r.range(-0.003, 0.003), - r.range(-0.003, 0.003), - ] - }; - let planes = [plane(), plane(), plane()]; - let warp = EmbeddedWarp { planes, center: Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), radius: r.range(0.5, 0.8) }; - wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None, ..Default::default() }); - wp.lens_dist = r.maybe(0.0, 2.0); - } - wp - } - - /// FNV-1a over the bits of the f64 warp results (`Affine::apply`, `Homography::apply`, `to_corrected`, - /// `from_corrected`, `corrected_to_source`, `to_source`, `embedded_warp`) for pseudo-random warps and points. - /// The expected value was captured before these formulas became generic over `Real`: the f64 path must keep - /// the same operations in the same order, bit for bit. To recompute it after changing the inputs, run this - /// test (printing `hash`) on a scratch copy of 419261c (thumb-split, pre-generic formulas) and paste it here. - #[test] - fn warp_f64_results_are_bit_identical_to_the_pre_generic_formulas() { - let mut hash = 0xCBF2_9CE4_8422_2325u64; - let mut eat = |p: Point| { - for v in [p.x, p.y] { - for b in v.to_bits().to_le_bytes() { - hash = (hash ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3); - } - } - }; - let mut r = Rng(42); - for _ in 0..2000 { - let wp = random_warp(&mut r); - let a = lightcraft_geom::Affine([ - r.range(-2.0, 2.0), - r.range(-2.0, 2.0), - r.range(-2.0, 2.0), - r.range(-2.0, 2.0), - r.range(-500.0, 500.0), - r.range(-500.0, 500.0), - ]); - for _ in 0..40 { - let p = Point::new(r.range(-0.2, 1.2) * wp.w, r.range(-0.2, 1.2) * wp.h); - eat(a.apply(p)); - eat(wp.persp_inv.apply(Point::new(p.x / wp.w - 0.5, p.y / wp.h - 0.5))); - eat(wp.to_corrected(p)); - eat(wp.from_corrected(p)); - for ch in 0..3 { - eat(wp.corrected_to_source(p, ch)); - eat(wp.to_source(p, ch)); - } - if let Some(ew) = wp.lens.and_then(|l| l.warp) { - for ch in 0..4 { - eat(embedded_warp(&ew, p, ch, wp.w, wp.h)); - } - } - } - } - // `Homography::apply`'s near-zero denominator clamp, including subnormal and signed-zero denominators and - // ±1e-300 exactly (not clamped: `<`, not `<=`) - let hz = Homography([1.0, 0.5, 0.25, -0.5, 1.0, 0.125, 1.0, 0.0, 0.0]); - for x in [0.0, -0.0, 1e-310, -1e-310, 1e-300, -1e-300, 1e-299, -1e-299, 0.5] { - eat(hz.apply(Point::new(x, 0.75))); - } - assert_eq!(hash, 0x3FA5_7473_D795_9E32, "f64 warp results changed"); - } - - /// The interval evaluation of `framing` bounds the f64 evaluation of every point of the box. - #[test] - fn interval_framing_encloses_every_f64_result() { - let mut r = Rng(7); - let (mut decided, mut total) = (0, 0); - for _ in 0..500 { - let wp = random_warp(&mut r); - let a = lightcraft_geom::Affine([ - r.range(0.5, 2.0), - r.range(-0.2, 0.2), - r.range(-0.2, 0.2), - r.range(0.5, 2.0), - r.range(-300.0, 300.0), - r.range(-300.0, 300.0), - ]); - for _ in 0..20 { - let (x0, y0) = (r.range(-0.2, 1.1) * wp.w, r.range(-0.2, 1.1) * wp.h); - let (bw, bh) = (r.range(0.0, 40.0), r.range(0.0, 40.0)); - let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); - let (_, (sx, sy)) = wp.framing(&a, ix, iy); - total += 1; - if !(sx.is_decided() && sy.is_decided()) { - continue; - } - decided += 1; - for i in 0..=6 { - for j in 0..=6 { - let (x, y) = if i == 6 && j == 6 { - (r.range(x0, x0 + bw), r.range(y0, y0 + bh)) - } else { - (x0 + bw * i as f64 / 5.0, y0 + bh * j as f64 / 5.0) - }; - let (x, y) = (x.clamp(ix.lo(), ix.hi()), y.clamp(iy.lo(), iy.hi())); - let (_, (fx, fy)) = wp.framing(&a, x, y); - assert!( - sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), - "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" - ); - } - } - } - } - assert!(decided * 10 > total * 9, "{decided}/{total} boxes bounded"); - } - - #[test] - fn block_coverage_agrees_with_every_pixel() { - let lens = EmbeddedLens { - warp: Some(EmbeddedWarp { - planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]], - center: Point::new(0.52, 0.48), - radius: 0.6, - }), - vignette: None, - ..Default::default() - }; - let mut cases: Vec = Vec::new(); - let mut s = DevelopSettings::default(); - s.optics.lens_profile = true; - s.optics.ca_red = 30.0; - s.geometry.horizontal = -15.0; - cases.push(s.clone()); - s.orientation = Orientation::Rotate270; - cases.push(s.clone()); - let mut s = DevelopSettings::default(); - s.optics.distortion = -60.0; - s.optics.ca_blue = 50.0; - s.geometry.vertical = 25.0; - s.geometry.rotate = 7.0; - cases.push(s.clone()); - s.optics.distortion = 80.0; - s.geometry.horizontal = 30.0; - s.orientation = Orientation::Rotate90; - cases.push(s); - for (i, s) in cases.iter().enumerate() { - let f = crate::geometry::Frame::with_lens(900, 600, s, true, Some(&lens)); - let wp = f.warp.as_ref().expect("warp"); - let (w, h) = f.fit(700, 700); - let o2t = f.out_to_oriented(w, h); - let (mut decided, mut tiles, mut blank) = (0, 0, 0); - for (tw, th) in [(32, 8), (7, 5), (64, 64)] { - for y0 in (0..h).step_by(th) { - for x0 in (0..w).step_by(tw) { - let (x1, y1) = ((x0 + tw).min(w), (y0 + th).min(h)); - tiles += 1; - let Some(all) = wp.block_coverage(&o2t, x0, x1, y0, y1) else { continue }; - decided += 1; - for y in y0..y1 { - for x in x0..x1 { - assert_eq!(wp.covers(&o2t, x, y), all, "case {i}: pixel {x},{y} in block {x0}..{x1} × {y0}..{y1}"); - blank += !all as usize; - } - } - } - } - } - // most blocks are decided wholesale, including blank ones away from the edge - assert!(decided * 10 > tiles * 8, "case {i}: {decided}/{tiles} blocks decided"); - // strong distortion leaves blank margins wide enough for whole blocks - assert!(blank > 0 || i < 2, "case {i}: no blank block decided"); - assert_eq!(wp.block_coverage(&o2t, 5, 5, 0, 1), None, "empty block"); - } - } - - /// A grid of dark lines on white with lateral CA: red magnified by `ar`, blue by `ab` (about the centre). - fn ca_grid(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f { - let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); - let val = |x: f64, y: f64| -> f32 { - // smooth-edged discs on a grid (edges in every direction) - let cell = w as f64 / 10.0; - let (fx, fy) = ((x / cell).fract() - 0.5, (y / cell).fract() - 0.5); - let d = fx.hypot(fy) * cell - cell * 0.28; - (0.05 + 0.85 * (d / 1.2).clamp(-0.5, 0.5) + 0.425) as f32 - }; - Rgb32f::from_fn(w, h, |x, y| { - let (x, y) = (x as f64 + 0.5, y as f64 + 0.5); - let at = |a: f64| val(cx + (x - cx) / (1.0 + a), cy + (y - cy) / (1.0 + a)); - [at(ar), at(0.0), at(ab)] - }) - } - - #[test] - fn ca_estimate_recovers_scale() { - let img = ca_grid(600, 400, 0.004, -0.003); - let [r, b] = estimate_lateral_ca_uncached(&img); - assert!((r - 0.004).abs() < 0.0008, "red {r}"); - assert!((b + 0.003).abs() < 0.0008, "blue {b}"); - let clean = ca_grid(600, 400, 0.0, 0.0); - let [r, b] = estimate_lateral_ca_uncached(&clean); - assert!(r.abs() < 0.0005 && b.abs() < 0.0005, "{r} {b}"); - } - - #[test] - fn warp_identity_and_distortion_direction() { - let s = DevelopSettings::default(); - let wp = Warp::from_settings(300.0, 200.0, &s, None); - assert!(wp.is_identity()); - let mut s = s; - s.optics.distortion = 50.0; // corrects barrel: corners sample from further in - let wp = Warp::from_settings(300.0, 200.0, &s, None); - let q = wp.to_source(Point::new(0.0, 0.0), 1); - assert!(q.x > 0.0 && q.y > 0.0); - let c = wp.to_source(Point::new(150.0, 100.0), 1); - assert!(c.dist(Point::new(150.0, 100.0)) < 1e-9); - } - - #[test] - fn manual_vignette_brightens_corners() { - let mut s = DevelopSettings::default(); - s.optics.vignetting = 100.0; - let wp = Warp::from_settings(300.0, 200.0, &s, None); - assert!((wp.gain(Point::new(150.0, 100.0)) - 1.0).abs() < 1e-6); - assert!(wp.gain(Point::new(0.0, 0.0)) > 2.0); - } - - #[test] - fn reorient_keeps_centre_mapping() { - let lens = EmbeddedLens { - warp: Some(EmbeddedWarp { planes: [[1.0, 0.01, 0.0, 0.0, 0.001, 0.002]; 3], center: Point::new(0.4, 0.5), radius: 0.6 }), - vignette: Some(EmbeddedVignette { k: [0.2, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.4, 0.5), radius: 0.6 }), - ..Default::default() - }; - let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); - // rotate 90° cw: (x, y) → (h − y, x) - let c = r.warp.unwrap().center; - assert!((c.x - 0.5).abs() < 1e-9 && (c.y - 0.4).abs() < 1e-9, "{c:?}"); - // a point's warped position transforms like the point itself - let p = Point::new(50.0, 30.0); - let s0 = embedded_warp(&lens.warp.unwrap(), p, 0, 300.0, 200.0); - let m = |q: Point| { - let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); - Point::new(x, y) - }; - let s1 = embedded_warp(&r.warp.unwrap(), m(p), 0, 200.0, 300.0); - assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); - } - - /// A `WarpRectilinear2` stage with the same coefficients on every plane. - fn stage(kr: &[(usize, f64)], center: Point, radius: f64, reciprocal: bool) -> EmbeddedWarpStage { - let mut k = [0.0; 19]; - for &(i, v) in kr { - k[i] = v; - } - k[18] = 1.0; - EmbeddedWarpStage { planes: [k; 3], center, radius, reciprocal } - } - - fn chain(stages: &[EmbeddedWarpStage]) -> Warp { - let mut wp = Warp::identity(300.0, 200.0); - let mut lens = EmbeddedLens::default(); - for (slot, st) in lens.stages.iter_mut().zip(stages) { - *slot = Some(*st); - } - wp.lens = Some(lens); - wp.lens_dist = 1.0; - wp - } - - #[test] - fn warp_rectilinear2_stages_identity_chain_order_and_reciprocal() { - let c = Point::new(0.5, 0.5); - let p = Point::new(40.0, 25.0); - // identity coefficients (kr0 = 1), plain and reciprocal, leave positions alone and don't move pixels - for reciprocal in [false, true] { - let wp = chain(&[stage(&[(0, 1.0)], c, 0.6, reciprocal)]); - assert!(wp.moves_pixels() && !wp.per_channel()); - assert!(wp.corrected_to_source(p, 1).dist(p) < 1e-9); - } - // the radial factor at r is f(r); with kr0 = 1, kr1 = 0.5: s = d·(1 + 0.5 r) (and d / (1 + 0.5 r) reciprocal) - let m = 0.6 * 300.0; - let d = Point::new((p.x - 150.0) / m, (p.y - 100.0) / m); - let r = d.x.hypot(d.y); - let fwd = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, false)]).corrected_to_source(p, 0); - assert!((fwd.x - (150.0 + m * d.x * (1.0 + 0.5 * r))).abs() < 1e-9); - let rec = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, true)]).corrected_to_source(p, 0); - assert!((rec.y - (100.0 + m * d.y / (1.0 + 0.5 * r))).abs() < 1e-9); - // list order: [A, B] maps an output position through B first, then A - let a = stage(&[(0, 1.0), (2, -0.3)], c, 0.6, false); - let b = stage(&[(0, 1.1), (1, 0.2)], Point::new(0.45, 0.5), 0.6, false); - let ab = chain(&[a, b]); - let by_hand = chain(&[a]).corrected_to_source(chain(&[b]).corrected_to_source(p, 1), 1); - assert!(ab.corrected_to_source(p, 1).dist(by_hand) < 1e-9); - assert!(ab.corrected_to_source(p, 1).dist(chain(&[b, a]).corrected_to_source(p, 1)) > 1e-3, "order matters"); - // strength scales the correction; a lens without a chain or warp does not move pixels - let mut half = ab.clone(); - half.lens_dist = 0.5; - let (full, mid) = (ab.corrected_to_source(p, 1), half.corrected_to_source(p, 1)); - assert!(mid.dist(Point::new((p.x + full.x) / 2.0, (p.y + full.y) / 2.0)) < 1e-9); - assert!(ab.needs_f64() && !chain(&[]).moves_pixels() && !Warp::identity(3.0, 3.0).needs_f64()); - // planes that differ (lateral CA) are sampled separately - let mut st = stage(&[(0, 1.0)], c, 0.6, false); - st.planes[0][0] = 1.01; - assert!(chain(&[st]).per_channel()); - } - - #[test] - fn warp_rectilinear2_valid_radius_clamps_the_polynomial() { - let c = Point::new(0.5, 0.5); - let mut st = stage(&[(0, 1.0), (1, 1.0)], c, 0.5, false); - st.planes.iter_mut().for_each(|k| (k[17], k[18]) = (0.2, 0.4)); - let wp = chain(&[st]); - let m = 0.5 * 300.0; - // far out (r > 0.4): f stays f(0.4) = 1.4 - let p = Point::new(10.0, 100.0); - let d = (p.x - 150.0) / m; - assert!(d.abs() > 0.4); - assert!((wp.corrected_to_source(p, 0).x - (150.0 + m * d * 1.4)).abs() < 1e-9); - // near the centre (r < 0.2): f stays f(0.2) = 1.2 - let p = Point::new(150.0 + 0.1 * m, 100.0); - assert!((wp.corrected_to_source(p, 0).x - (150.0 + 0.1 * m * 1.2)).abs() < 1e-9); - } - - #[test] - fn warp_rectilinear2_interval_framing_encloses_every_f64_result() { - let mut r = Rng(11); - for _ in 0..200 { - let mut stages = Vec::new(); - for _ in 0..2 { - let mut st = stage(&[], Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), r.range(0.5, 0.8), r.next().is_multiple_of(3)); - for k in st.planes.iter_mut() { - k[0] = 1.0 + r.range(-0.05, 0.05); - for v in k[1..15].iter_mut() { - *v = r.maybe(-0.5, 0.5); - } - (k[15], k[16]) = (r.range(-0.003, 0.003), r.range(-0.003, 0.003)); - (k[17], k[18]) = (r.maybe(0.0, 0.3), r.range(0.5, 1.0)); - } - stages.push(st); - } - let wp = chain(&stages); - let a = lightcraft_geom::Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); - for _ in 0..20 { - let (x0, y0) = (r.range(0.0, 1.0) * wp.w, r.range(0.0, 1.0) * wp.h); - let (bw, bh) = (r.range(0.0, 20.0), r.range(0.0, 20.0)); - let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); - let (_, (sx, sy)) = wp.framing(&a, ix, iy); - if !(sx.is_decided() && sy.is_decided()) { - continue; - } - for i in 0..=4 { - for j in 0..=4 { - let (x, y) = ((x0 + bw * i as f64 / 4.0).clamp(ix.lo(), ix.hi()), (y0 + bh * j as f64 / 4.0).clamp(iy.lo(), iy.hi())); - let (_, (fx, fy)) = wp.framing(&a, x, y); - assert!( - sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), - "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" - ); - } - } - } - } - } - - #[test] - fn reorient_rotates_a_warp_rectilinear2_stage_like_the_point() { - let mut st = stage(&[(0, 1.0), (1, 0.05), (2, -0.1)], Point::new(0.4, 0.5), 0.6, false); - st.planes.iter_mut().for_each(|k| (k[15], k[16]) = (0.001, 0.002)); - let mut lens = EmbeddedLens::default(); - lens.stages[0] = Some(st); - let p = Point::new(50.0, 30.0); - let m = |q: Point| { - let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); - Point::new(x, y) - }; - let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); - let s0 = embedded_stage(&st, p, 0, 300.0, 200.0); - let s1 = embedded_stage(&r.stages[0].unwrap(), m(p), 0, 200.0, 300.0); - assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); - assert_eq!(reorient_lens(&lens, Orientation::Normal, 300.0, 200.0), lens); - } - - #[test] - fn defringe_removes_purple_at_edges_only() { - let mut s = DevelopSettings::default(); - s.optics.defringe_purple_amount = 10.0; - // white | black edge with a purple column next to it, and a purple patch far from edges - let purple = [0.25f32, 0.05, 0.45]; - let mut img = Rgb32f::from_fn(40, 20, |x, _| if x < 20 { [0.9; 3] } else { [0.02; 3] }); - for y in 0..20 { - img.set(20, y, purple); - } - let flat = Rgb32f::filled(40, 20, purple); - let mut flat2 = flat.clone(); - defringe(&mut img, &s, 1.0); - defringe(&mut flat2, &s, 1.0); - let chroma = |p: [f32; 3]| { - let l = oklab_from_2020(p); - l[1].hypot(l[2]) - }; - assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3); - assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4); - } - - /// Found by fuzzing catalog snapshots: a defringe amount far past the sliders' 20 (from a - /// damaged catalog or sidecar) made the filter's radius overflow (`x + r`), which panics. - /// It acts as the sliders' end. - #[test] - fn defringe_amounts_past_the_sliders_act_as_their_end() { - let purple = [0.25f32, 0.05, 0.45]; - let scene = Rgb32f::from_fn(40, 20, |x, _| { - if x < 20 { - [0.9; 3] - } else if x == 20 { - purple - } else { - [0.02; 3] - } - }); - let at = |amount: f64, scale: f64| { - let mut s = DevelopSettings::default(); - s.optics.defringe_purple_amount = amount; - s.optics.defringe_green_amount = amount / 2.0; - let mut img = scene.clone(); - defringe(&mut img, &s, scale); - img - }; - assert_eq!(at(1e300, 1.0).data, at(20.0, 1.0).data); - assert_eq!(at(f64::MAX, 1e300).data.len(), 800, "a huge scale too"); - } -} +mod tests; diff --git a/crates/pipeline/src/optics/tests.rs b/crates/pipeline/src/optics/tests.rs new file mode 100644 index 000000000..a404222e8 --- /dev/null +++ b/crates/pipeline/src/optics/tests.rs @@ -0,0 +1,477 @@ +use super::*; + +// An empty image (no pixel to sample) has a fingerprint, its dimensions' (indexing its first pixel panicked) +#[test] +fn an_empty_image_has_a_fingerprint() { + assert_eq!(fingerprint(&Rgb32f::new(0, 0)), fingerprint(&Rgb32f::new(0, 0))); + assert_ne!(fingerprint(&Rgb32f::new(0, 3)), fingerprint(&Rgb32f::new(3, 0))); +} + +/// splitmix64: deterministic pseudo-random numbers for the warp tests. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// Uniform in `lo..hi`. + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (hi - lo) * ((self.next() >> 11) as f64 / (1u64 << 53) as f64) + } + /// `range(lo, hi)` or, one time in four, exactly 0 (to take the "no correction" branches). + fn maybe(&mut self, lo: f64, hi: f64) -> f64 { + if self.next().is_multiple_of(4) { 0.0 } else { self.range(lo, hi) } + } +} + +/// A pseudo-random warp: perspective, manual distortion, CA and an embedded lens warp, each sometimes off. +/// Sizes are Pythagorean (exact `hypot`), so results don't depend on the platform's libm. +fn random_warp(r: &mut Rng) -> Warp { + const SIZES: [(f64, f64); 6] = [(4000.0, 3000.0), (3000.0, 4000.0), (1200.0, 900.0), (1200.0, 500.0), (56.0, 33.0), (6000.0, 4500.0)]; + let (w, h) = SIZES[(r.next() % SIZES.len() as u64) as usize]; + let mut wp = Warp::identity(w, h); + if !r.next().is_multiple_of(4) { + let m = [ + 1.0 + r.range(-0.2, 0.2), + r.range(-0.2, 0.2), + r.range(-0.1, 0.1), + r.range(-0.2, 0.2), + 1.0 + r.range(-0.2, 0.2), + r.range(-0.1, 0.1), + r.range(-0.4, 0.4), + r.range(-0.4, 0.4), + 1.0 + r.range(-0.1, 0.1), + ]; + wp.persp_inv = Homography(m); + wp.persp = wp.persp_inv.inverse().unwrap_or(Homography::IDENTITY); + } + wp.k1 = r.maybe(-0.2, 0.2); + wp.ca = [r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003)]; + if !r.next().is_multiple_of(4) { + let mut plane = || { + [ + 1.0 + r.range(-0.05, 0.05), + r.range(-0.08, 0.08), + r.range(-0.05, 0.05), + r.range(-0.02, 0.02), + r.range(-0.003, 0.003), + r.range(-0.003, 0.003), + ] + }; + let planes = [plane(), plane(), plane()]; + let warp = EmbeddedWarp { planes, center: Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), radius: r.range(0.5, 0.8) }; + wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None, ..Default::default() }); + wp.lens_dist = r.maybe(0.0, 2.0); + } + wp +} + +/// FNV-1a over the bits of the f64 warp results (`Affine::apply`, `Homography::apply`, `to_corrected`, +/// `from_corrected`, `corrected_to_source`, `to_source`, `embedded_warp`) for pseudo-random warps and points. +/// The expected value was captured before these formulas became generic over `Real`: the f64 path must keep +/// the same operations in the same order, bit for bit. To recompute it after changing the inputs, run this +/// test (printing `hash`) on a scratch copy of 419261c (thumb-split, pre-generic formulas) and paste it here. +#[test] +fn warp_f64_results_are_bit_identical_to_the_pre_generic_formulas() { + let mut hash = 0xCBF2_9CE4_8422_2325u64; + let mut eat = |p: Point| { + for v in [p.x, p.y] { + for b in v.to_bits().to_le_bytes() { + hash = (hash ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3); + } + } + }; + let mut r = Rng(42); + for _ in 0..2000 { + let wp = random_warp(&mut r); + let a = lightcraft_geom::Affine([ + r.range(-2.0, 2.0), + r.range(-2.0, 2.0), + r.range(-2.0, 2.0), + r.range(-2.0, 2.0), + r.range(-500.0, 500.0), + r.range(-500.0, 500.0), + ]); + for _ in 0..40 { + let p = Point::new(r.range(-0.2, 1.2) * wp.w, r.range(-0.2, 1.2) * wp.h); + eat(a.apply(p)); + eat(wp.persp_inv.apply(Point::new(p.x / wp.w - 0.5, p.y / wp.h - 0.5))); + eat(wp.to_corrected(p)); + eat(wp.from_corrected(p)); + for ch in 0..3 { + eat(wp.corrected_to_source(p, ch)); + eat(wp.to_source(p, ch)); + } + if let Some(ew) = wp.lens.and_then(|l| l.warp) { + for ch in 0..4 { + eat(embedded_warp(&ew, p, ch, wp.w, wp.h)); + } + } + } + } + // `Homography::apply`'s near-zero denominator clamp, including subnormal and signed-zero denominators and + // ±1e-300 exactly (not clamped: `<`, not `<=`) + let hz = Homography([1.0, 0.5, 0.25, -0.5, 1.0, 0.125, 1.0, 0.0, 0.0]); + for x in [0.0, -0.0, 1e-310, -1e-310, 1e-300, -1e-300, 1e-299, -1e-299, 0.5] { + eat(hz.apply(Point::new(x, 0.75))); + } + assert_eq!(hash, 0x3FA5_7473_D795_9E32, "f64 warp results changed"); +} + +/// The interval evaluation of `framing` bounds the f64 evaluation of every point of the box. +#[test] +fn interval_framing_encloses_every_f64_result() { + let mut r = Rng(7); + let (mut decided, mut total) = (0, 0); + for _ in 0..500 { + let wp = random_warp(&mut r); + let a = lightcraft_geom::Affine([ + r.range(0.5, 2.0), + r.range(-0.2, 0.2), + r.range(-0.2, 0.2), + r.range(0.5, 2.0), + r.range(-300.0, 300.0), + r.range(-300.0, 300.0), + ]); + for _ in 0..20 { + let (x0, y0) = (r.range(-0.2, 1.1) * wp.w, r.range(-0.2, 1.1) * wp.h); + let (bw, bh) = (r.range(0.0, 40.0), r.range(0.0, 40.0)); + let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); + let (_, (sx, sy)) = wp.framing(&a, ix, iy); + total += 1; + if !(sx.is_decided() && sy.is_decided()) { + continue; + } + decided += 1; + for i in 0..=6 { + for j in 0..=6 { + let (x, y) = if i == 6 && j == 6 { + (r.range(x0, x0 + bw), r.range(y0, y0 + bh)) + } else { + (x0 + bw * i as f64 / 5.0, y0 + bh * j as f64 / 5.0) + }; + let (x, y) = (x.clamp(ix.lo(), ix.hi()), y.clamp(iy.lo(), iy.hi())); + let (_, (fx, fy)) = wp.framing(&a, x, y); + assert!(sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}"); + } + } + } + } + assert!(decided * 10 > total * 9, "{decided}/{total} boxes bounded"); +} + +#[test] +fn block_coverage_agrees_with_every_pixel() { + let lens = EmbeddedLens { + warp: Some(EmbeddedWarp { + planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]], + center: Point::new(0.52, 0.48), + radius: 0.6, + }), + vignette: None, + ..Default::default() + }; + let mut cases: Vec = Vec::new(); + let mut s = DevelopSettings::default(); + s.optics.lens_profile = true; + s.optics.ca_red = 30.0; + s.geometry.horizontal = -15.0; + cases.push(s.clone()); + s.orientation = Orientation::Rotate270; + cases.push(s.clone()); + let mut s = DevelopSettings::default(); + s.optics.distortion = -60.0; + s.optics.ca_blue = 50.0; + s.geometry.vertical = 25.0; + s.geometry.rotate = 7.0; + cases.push(s.clone()); + s.optics.distortion = 80.0; + s.geometry.horizontal = 30.0; + s.orientation = Orientation::Rotate90; + cases.push(s); + for (i, s) in cases.iter().enumerate() { + let f = crate::geometry::Frame::with_lens(900, 600, s, true, Some(&lens)); + let wp = f.warp.as_ref().expect("warp"); + let (w, h) = f.fit(700, 700); + let o2t = f.out_to_oriented(w, h); + let (mut decided, mut tiles, mut blank) = (0, 0, 0); + for (tw, th) in [(32, 8), (7, 5), (64, 64)] { + for y0 in (0..h).step_by(th) { + for x0 in (0..w).step_by(tw) { + let (x1, y1) = ((x0 + tw).min(w), (y0 + th).min(h)); + tiles += 1; + let Some(all) = wp.block_coverage(&o2t, x0, x1, y0, y1) else { continue }; + decided += 1; + for y in y0..y1 { + for x in x0..x1 { + assert_eq!(wp.covers(&o2t, x, y), all, "case {i}: pixel {x},{y} in block {x0}..{x1} × {y0}..{y1}"); + blank += !all as usize; + } + } + } + } + } + // most blocks are decided wholesale, including blank ones away from the edge + assert!(decided * 10 > tiles * 8, "case {i}: {decided}/{tiles} blocks decided"); + // strong distortion leaves blank margins wide enough for whole blocks + assert!(blank > 0 || i < 2, "case {i}: no blank block decided"); + assert_eq!(wp.block_coverage(&o2t, 5, 5, 0, 1), None, "empty block"); + } +} + +/// A grid of dark lines on white with lateral CA: red magnified by `ar`, blue by `ab` (about the centre). +fn ca_grid(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f { + let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); + let val = |x: f64, y: f64| -> f32 { + // smooth-edged discs on a grid (edges in every direction) + let cell = w as f64 / 10.0; + let (fx, fy) = ((x / cell).fract() - 0.5, (y / cell).fract() - 0.5); + let d = fx.hypot(fy) * cell - cell * 0.28; + (0.05 + 0.85 * (d / 1.2).clamp(-0.5, 0.5) + 0.425) as f32 + }; + Rgb32f::from_fn(w, h, |x, y| { + let (x, y) = (x as f64 + 0.5, y as f64 + 0.5); + let at = |a: f64| val(cx + (x - cx) / (1.0 + a), cy + (y - cy) / (1.0 + a)); + [at(ar), at(0.0), at(ab)] + }) +} + +#[test] +fn ca_estimate_recovers_scale() { + let img = ca_grid(600, 400, 0.004, -0.003); + let [r, b] = estimate_lateral_ca_uncached(&img); + assert!((r - 0.004).abs() < 0.0008, "red {r}"); + assert!((b + 0.003).abs() < 0.0008, "blue {b}"); + let clean = ca_grid(600, 400, 0.0, 0.0); + let [r, b] = estimate_lateral_ca_uncached(&clean); + assert!(r.abs() < 0.0005 && b.abs() < 0.0005, "{r} {b}"); +} + +#[test] +fn warp_identity_and_distortion_direction() { + let s = DevelopSettings::default(); + let wp = Warp::from_settings(300.0, 200.0, &s, None); + assert!(wp.is_identity()); + let mut s = s; + s.optics.distortion = 50.0; // corrects barrel: corners sample from further in + let wp = Warp::from_settings(300.0, 200.0, &s, None); + let q = wp.to_source(Point::new(0.0, 0.0), 1); + assert!(q.x > 0.0 && q.y > 0.0); + let c = wp.to_source(Point::new(150.0, 100.0), 1); + assert!(c.dist(Point::new(150.0, 100.0)) < 1e-9); +} + +#[test] +fn manual_vignette_brightens_corners() { + let mut s = DevelopSettings::default(); + s.optics.vignetting = 100.0; + let wp = Warp::from_settings(300.0, 200.0, &s, None); + assert!((wp.gain(Point::new(150.0, 100.0)) - 1.0).abs() < 1e-6); + assert!(wp.gain(Point::new(0.0, 0.0)) > 2.0); +} + +#[test] +fn reorient_keeps_centre_mapping() { + let lens = EmbeddedLens { + warp: Some(EmbeddedWarp { planes: [[1.0, 0.01, 0.0, 0.0, 0.001, 0.002]; 3], center: Point::new(0.4, 0.5), radius: 0.6 }), + vignette: Some(EmbeddedVignette { k: [0.2, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.4, 0.5), radius: 0.6 }), + ..Default::default() + }; + let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); + // rotate 90° cw: (x, y) → (h − y, x) + let c = r.warp.unwrap().center; + assert!((c.x - 0.5).abs() < 1e-9 && (c.y - 0.4).abs() < 1e-9, "{c:?}"); + // a point's warped position transforms like the point itself + let p = Point::new(50.0, 30.0); + let s0 = embedded_warp(&lens.warp.unwrap(), p, 0, 300.0, 200.0); + let m = |q: Point| { + let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); + Point::new(x, y) + }; + let s1 = embedded_warp(&r.warp.unwrap(), m(p), 0, 200.0, 300.0); + assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); +} + +/// A `WarpRectilinear2` stage with the same coefficients on every plane. +fn stage(kr: &[(usize, f64)], center: Point, radius: f64, reciprocal: bool) -> EmbeddedWarpStage { + let mut k = [0.0; 19]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + EmbeddedWarpStage { planes: [k; 3], center, radius, reciprocal } +} + +fn chain(stages: &[EmbeddedWarpStage]) -> Warp { + let mut wp = Warp::identity(300.0, 200.0); + let mut lens = EmbeddedLens::default(); + for (slot, st) in lens.stages.iter_mut().zip(stages) { + *slot = Some(*st); + } + wp.lens = Some(lens); + wp.lens_dist = 1.0; + wp +} + +#[test] +fn warp_rectilinear2_stages_identity_chain_order_and_reciprocal() { + let c = Point::new(0.5, 0.5); + let p = Point::new(40.0, 25.0); + // identity coefficients (kr0 = 1), plain and reciprocal, leave positions alone and don't move pixels + for reciprocal in [false, true] { + let wp = chain(&[stage(&[(0, 1.0)], c, 0.6, reciprocal)]); + assert!(wp.moves_pixels() && !wp.per_channel()); + assert!(wp.corrected_to_source(p, 1).dist(p) < 1e-9); + } + // the radial factor at r is f(r); with kr0 = 1, kr1 = 0.5: s = d·(1 + 0.5 r) (and d / (1 + 0.5 r) reciprocal) + let m = 0.6 * 300.0; + let d = Point::new((p.x - 150.0) / m, (p.y - 100.0) / m); + let r = d.x.hypot(d.y); + let fwd = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, false)]).corrected_to_source(p, 0); + assert!((fwd.x - (150.0 + m * d.x * (1.0 + 0.5 * r))).abs() < 1e-9); + let rec = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, true)]).corrected_to_source(p, 0); + assert!((rec.y - (100.0 + m * d.y / (1.0 + 0.5 * r))).abs() < 1e-9); + // list order: [A, B] maps an output position through B first, then A + let a = stage(&[(0, 1.0), (2, -0.3)], c, 0.6, false); + let b = stage(&[(0, 1.1), (1, 0.2)], Point::new(0.45, 0.5), 0.6, false); + let ab = chain(&[a, b]); + let by_hand = chain(&[a]).corrected_to_source(chain(&[b]).corrected_to_source(p, 1), 1); + assert!(ab.corrected_to_source(p, 1).dist(by_hand) < 1e-9); + assert!(ab.corrected_to_source(p, 1).dist(chain(&[b, a]).corrected_to_source(p, 1)) > 1e-3, "order matters"); + // strength scales the correction; a lens without a chain or warp does not move pixels + let mut half = ab.clone(); + half.lens_dist = 0.5; + let (full, mid) = (ab.corrected_to_source(p, 1), half.corrected_to_source(p, 1)); + assert!(mid.dist(Point::new((p.x + full.x) / 2.0, (p.y + full.y) / 2.0)) < 1e-9); + assert!(ab.needs_f64() && !chain(&[]).moves_pixels() && !Warp::identity(3.0, 3.0).needs_f64()); + // planes that differ (lateral CA) are sampled separately + let mut st = stage(&[(0, 1.0)], c, 0.6, false); + st.planes[0][0] = 1.01; + assert!(chain(&[st]).per_channel()); +} + +#[test] +fn warp_rectilinear2_valid_radius_clamps_the_polynomial() { + let c = Point::new(0.5, 0.5); + let mut st = stage(&[(0, 1.0), (1, 1.0)], c, 0.5, false); + st.planes.iter_mut().for_each(|k| (k[17], k[18]) = (0.2, 0.4)); + let wp = chain(&[st]); + let m = 0.5 * 300.0; + // far out (r > 0.4): f stays f(0.4) = 1.4 + let p = Point::new(10.0, 100.0); + let d = (p.x - 150.0) / m; + assert!(d.abs() > 0.4); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + m * d * 1.4)).abs() < 1e-9); + // near the centre (r < 0.2): f stays f(0.2) = 1.2 + let p = Point::new(150.0 + 0.1 * m, 100.0); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + 0.1 * m * 1.2)).abs() < 1e-9); +} + +#[test] +fn warp_rectilinear2_interval_framing_encloses_every_f64_result() { + let mut r = Rng(11); + for _ in 0..200 { + let mut stages = Vec::new(); + for _ in 0..2 { + let mut st = stage(&[], Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), r.range(0.5, 0.8), r.next().is_multiple_of(3)); + for k in st.planes.iter_mut() { + k[0] = 1.0 + r.range(-0.05, 0.05); + for v in k[1..15].iter_mut() { + *v = r.maybe(-0.5, 0.5); + } + (k[15], k[16]) = (r.range(-0.003, 0.003), r.range(-0.003, 0.003)); + (k[17], k[18]) = (r.maybe(0.0, 0.3), r.range(0.5, 1.0)); + } + stages.push(st); + } + let wp = chain(&stages); + let a = lightcraft_geom::Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for _ in 0..20 { + let (x0, y0) = (r.range(0.0, 1.0) * wp.w, r.range(0.0, 1.0) * wp.h); + let (bw, bh) = (r.range(0.0, 20.0), r.range(0.0, 20.0)); + let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); + let (_, (sx, sy)) = wp.framing(&a, ix, iy); + if !(sx.is_decided() && sy.is_decided()) { + continue; + } + for i in 0..=4 { + for j in 0..=4 { + let (x, y) = ((x0 + bw * i as f64 / 4.0).clamp(ix.lo(), ix.hi()), (y0 + bh * j as f64 / 4.0).clamp(iy.lo(), iy.hi())); + let (_, (fx, fy)) = wp.framing(&a, x, y); + assert!(sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}"); + } + } + } + } +} + +#[test] +fn reorient_rotates_a_warp_rectilinear2_stage_like_the_point() { + let mut st = stage(&[(0, 1.0), (1, 0.05), (2, -0.1)], Point::new(0.4, 0.5), 0.6, false); + st.planes.iter_mut().for_each(|k| (k[15], k[16]) = (0.001, 0.002)); + let mut lens = EmbeddedLens::default(); + lens.stages[0] = Some(st); + let p = Point::new(50.0, 30.0); + let m = |q: Point| { + let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); + Point::new(x, y) + }; + let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); + let s0 = embedded_stage(&st, p, 0, 300.0, 200.0); + let s1 = embedded_stage(&r.stages[0].unwrap(), m(p), 0, 200.0, 300.0); + assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); + assert_eq!(reorient_lens(&lens, Orientation::Normal, 300.0, 200.0), lens); +} + +#[test] +fn defringe_removes_purple_at_edges_only() { + let mut s = DevelopSettings::default(); + s.optics.defringe_purple_amount = 10.0; + // white | black edge with a purple column next to it, and a purple patch far from edges + let purple = [0.25f32, 0.05, 0.45]; + let mut img = Rgb32f::from_fn(40, 20, |x, _| if x < 20 { [0.9; 3] } else { [0.02; 3] }); + for y in 0..20 { + img.set(20, y, purple); + } + let flat = Rgb32f::filled(40, 20, purple); + let mut flat2 = flat.clone(); + defringe(&mut img, &s, 1.0); + defringe(&mut flat2, &s, 1.0); + let chroma = |p: [f32; 3]| { + let l = oklab_from_2020(p); + l[1].hypot(l[2]) + }; + assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3); + assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4); +} + +/// Found by fuzzing catalog snapshots: a defringe amount far past the sliders' 20 (from a +/// damaged catalog or sidecar) made the filter's radius overflow (`x + r`), which panics. +/// It acts as the sliders' end. +#[test] +fn defringe_amounts_past_the_sliders_act_as_their_end() { + let purple = [0.25f32, 0.05, 0.45]; + let scene = Rgb32f::from_fn(40, 20, |x, _| { + if x < 20 { + [0.9; 3] + } else if x == 20 { + purple + } else { + [0.02; 3] + } + }); + let at = |amount: f64, scale: f64| { + let mut s = DevelopSettings::default(); + s.optics.defringe_purple_amount = amount; + s.optics.defringe_green_amount = amount / 2.0; + let mut img = scene.clone(); + defringe(&mut img, &s, scale); + img + }; + assert_eq!(at(1e300, 1.0).data, at(20.0, 1.0).data); + assert_eq!(at(f64::MAX, 1e300).data.len(), 800, "a huge scale too"); +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 76d48872d..be2ba8d44 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -268,6 +268,27 @@ impl Rgba8 { } Some(Self { width, height, data: bytes.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect() }) } + + /// Build an image from exactly `width * height` RGBA pixels. + /// + /// This copies each pixel once, straight into the image's own buffer, so a caller that already + /// holds a pixel array (an egui `ColorImage`, say) does not need a second full byte vector. + /// `None` for a zero or overflowing size, or a pixel count that is not exactly `width * height`. + pub fn from_pixels(width: usize, height: usize, pixels: impl IntoIterator) -> Option { + if width == 0 || height == 0 { + return None; + } + let expected = width.checked_mul(height)?; + let mut data = Vec::new(); + data.try_reserve_exact(expected).ok()?; + for (i, pixel) in pixels.into_iter().enumerate() { + if i == expected { + return None; + } + data.push(pixel); + } + (data.len() == expected).then_some(Self { width, height, data }) + } /// Linear Rec.709/sRGB primaries float image from 8-bit sRGB. pub fn to_linear(&self) -> Rgb32f { use lightcraft_color::transfer::decode_srgb8; @@ -356,4 +377,15 @@ mod tests { assert_eq!(img.to_linear().to_srgb8(), img); assert_eq!(Rgba8::from_bytes(16, 16, &img.as_bytes()).unwrap(), img); } + + #[test] + fn from_pixels_requires_exactly_the_stated_pixels() { + let img = Rgba8::from_fn(4, 3, |x, y| [x as u8, y as u8, 9, 255]); + let rebuilt = Rgba8::from_pixels(4, 3, img.as_bytes().as_chunks::<4>().0.iter().map(|p| [p[0], p[1], p[2], p[3]])).unwrap(); + assert_eq!(rebuilt, img); + assert!(Rgba8::from_pixels(4, 3, vec![[0, 0, 0, 0]; 11]).is_none(), "too few pixels"); + assert!(Rgba8::from_pixels(4, 3, vec![[0, 0, 0, 0]; 13]).is_none(), "too many pixels"); + assert!(Rgba8::from_pixels(0, 3, std::iter::empty()).is_none()); + assert!(Rgba8::from_pixels(usize::MAX, 2, std::iter::empty()).is_none()); + } } diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic.rs similarity index 100% rename from crates/raw/src/demosaic/mod.rs rename to crates/raw/src/demosaic.rs diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index d7ba42776..fae6015b6 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -94,6 +94,17 @@ pub const SOFTWARE: &str = "LightCraft DNG 2"; /// (3.9 s vs 0.55 s on 4 cores) to save 3 % of the file (266 vs 275 MB); 5–12 % on raw and rendered DNGs. const DEFLATE_LEVEL: u8 = 1; +/// Largest Deflate tile edge the writer pads to (`tile` is caller-supplied in [`DngCompression::Deflate`]): +/// square-tile buffers grow with the square, so an unclamped edge lets a request pad a tiny valid image into +/// a multi-gigabyte buffer and billions of edge-replicate iterations. 2048² samples (≤ 24 MB of f32) is far +/// above the 256 default and every real tile this writer makes. +const MAX_DEFLATE_TILE: u32 = 2048; + +/// `tile` aligned down to a multiple of 16 and clamped to [`MAX_DEFLATE_TILE`]. +fn deflate_tile(tile: u32) -> usize { + (tile.max(16) & !15).min(MAX_DEFLATE_TILE) as usize +} + /// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept). pub fn f32_to_f16(v: f32) -> u16 { let b = v.to_bits(); @@ -318,7 +329,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { match (&raw.data, opts.compression) { (RawData::F32(v), DngCompression::Deflate { tile, half }) => { - let tile = (tile.max(16) & !15) as usize; + let tile = deflate_tile(tile); let bp = if half { 2 } else { 4 }; ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp])); ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp])); @@ -417,7 +428,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { ifd.set_image(ImageData::Strips { rows_per_strip: rows as u32, strips }); } DngCompression::Deflate { tile, .. } => { - let tile = (tile.max(16) & !15) as usize; + let tile = deflate_tile(tile); ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16; cpp])); ifd.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE])); ifd.set(t::PREDICTOR, Value::Short(vec![2])); @@ -546,3 +557,45 @@ pub fn write_rendered_dng( let order = ByteOrder::Little; write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BlackLevel, OpcodeLists, RawData, RawFormat, RawImage, Rect}; + + /// Deflate tiles of `tile × tile` pad into a square buffer that grows with the square: a + /// caller-supplied edge of `u32::MAX` would make the writer pad an 8 × 8 image into billions of + /// edge-replicate iterations and a multi-gigabyte buffer. The clamp keeps the bounded shape — same + /// file as requesting the cap exactly, and a DNG either way. + #[test] + fn a_hostile_deflate_tile_edge_is_clamped() { + let (w, h) = (8usize, 8usize); + let full = Rect::new(0, 0, w, h); + let raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..64).map(|i| i as u16 * 129).collect()), + cfa: None, + bits: 12, + black: BlackLevel::uniform(0.0), + white: vec![4095.0], + active_area: full, + crop: full, + orientation: lightcraft_geom::Orientation::Normal, + color: crate::ColorData::default(), + wb_multipliers: None, + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: Default::default(), + lens_metadata: Default::default(), + }; + let opts = |tile: u32| DngWriteOptions { compression: DngCompression::Deflate { tile, half: false }, ..DngWriteOptions::default() }; + let hostile = write_dng(&raw, &opts(u32::MAX)).expect("the clamped tile writes"); + assert_eq!(hostile, write_dng(&raw, &opts(2048)).expect("the cap itself writes"), "u32::MAX is clamped to the cap"); + assert_ne!(hostile, write_dng(&raw, &opts(256)).expect("a normal tile writes"), "the clamp is not a no-op"); + assert_eq!(crate::probe(&hostile), Some(RawFormat::Dng), "the clamped output is still a DNG"); + } +} diff --git a/crates/raw/src/highlight.rs b/crates/raw/src/highlight.rs index bfebfe814..40f35dc20 100644 --- a/crates/raw/src/highlight.rs +++ b/crates/raw/src/highlight.rs @@ -531,351 +531,4 @@ fn rebuild_clipped(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32; 3], c } #[cfg(test)] -mod tests { - use super::*; - - /// Daylight-like multipliers: green clips first, red and blue keep rising. - const WB: [f32; 3] = [2.5, 1.0, 1.6]; - - fn balanced(p: [f32; 3]) -> [f32; 3] { - [p[0] * WB[0], p[1] * WB[1], p[2] * WB[2]] - } - - fn sensor(q: [f32; 3]) -> [f32; 3] { - [q[0] / WB[0], q[1] / WB[1], q[2] / WB[2]].map(|v| v.min(1.0)) - } - - fn chroma(q: [f32; 3]) -> [f32; 3] { - let s = q[0] + q[1] + q[2]; - q.map(|v| v / s) - } - - /// A pale blue sky whose green channel clipped, an unclipped strip of the same sky along the top, and a dark - /// branch with purple fringes on both sides standing in the clipped part (white-balanced sky colour returned). - fn branch_scene() -> (Rgb32f, [f32; 3]) { - let sky = [1.10, 1.25, 1.45]; - let img = Rgb32f::from_fn(192, 128, |x, y| { - sensor(if y < 24 { - sky.map(|v| v * 0.7) - } else if (90..100).contains(&x) && y >= 40 { - [0.06, 0.05, 0.03] - } else if (x == 89 || x == 100) && y >= 40 { - [0.75, 0.25, 0.96] - } else { - sky - }) - }); - (img, sky) - } - - /// How far chromaticity `got` lies off the way from `from` to neutral (largest channel difference to the - /// nearest point of that way). - fn off_the_way_to_neutral(got: [f32; 3], from: [f32; 3]) -> f32 { - let d: [f32; 3] = std::array::from_fn(|c| 1.0 / 3.0 - from[c]); - let len = d.iter().map(|v| v * v).sum::(); - let t = if len > 0.0 { ((0..3).map(|c| (got[c] - from[c]) * d[c]).sum::() / len).clamp(0.0, 1.0) } else { 0.0 }; - (0..3).map(|c| (got[c] - (from[c] + d[c] * t)).abs()).fold(0.0, f32::max) - } - - /// Issue #523: the clipped sky beside a branch took the colour of the branch's fringes and of the branch - /// itself (magenta, green), which any darkening of the highlights then showed. Beside the branch it is now the - /// clipped sky away from it: the sky's colour, on its way to neutral. - #[test] - fn fringes_and_dark_branches_do_not_tint_a_clipped_sky() { - let (mut img, sky) = branch_scene(); - assert!(reconstruct(&mut img, WB, 0.99) > 0); - let mut worst = 0f32; - for y in 40..128 { - let away = chroma(balanced(img.get(30, y))); - assert!(away == chroma(balanced(img.get(170, y))), "row {y}"); - let off = off_the_way_to_neutral(away, chroma(sky)); - assert!(off < 0.005, "row {y}: the clipped sky {away:?} is not the sky's colour on its way to neutral ({off})"); - for x in [84, 86, 88, 101, 103, 105] { - let got = chroma(balanced(img.get(x, y))); - worst = worst.max((0..3).map(|c| (got[c] - away[c]).abs()).fold(0.0, f32::max)); - } - } - assert!(worst < 0.01, "clipped sky beside the branch is off the sky away from it by {worst}"); - } - - /// A warm light with red and green clipped: left at their clip levels the pair reads red-magenta (the white - /// balance multiplies red's clip level by 2.5, green's by 1). - #[test] - fn two_clipped_channels_are_not_left_at_their_clip_levels() { - let light = [3.5, 2.6, 0.8]; - let mut img = Rgb32f::from_fn(64, 64, |x, y| { - let d = ((x as f32 - 32.0).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); - if d < 20.0 { sensor(light) } else { [0.02; 3] } - }); - reconstruct(&mut img, WB, 0.99); - let q = balanced(img.get(32, 32)); - assert!((q[2] - 0.8).abs() < 0.1, "the unclipped channel is about kept: {q:?}"); - let ratio = q[1] / q[0]; - assert!((0.6..=1.05).contains(&ratio), "green / red {ratio} ({q:?}): not a warm white"); - } - - #[test] - fn fully_clipped_pixels_follow_the_colour_model_white() { - let white = [1.03, 0.98, 1.06]; - let mut img = Rgb32f::filled(8, 8, [1.0; 3]); - img.data[0] = [0.3, 0.6, 0.4]; - reconstruct_with(&mut img, WB, 0.99, white); - let q = balanced(img.get(4, 4)); - let k = q[1] / white[1]; - assert!((0..3).all(|c| (q[c] - white[c] * k).abs() < 1e-4 * k), "{q:?} is not along {white:?}"); - assert!(k >= 2.5 * 0.99 - 1e-4, "clipped neutral at least as bright as the highest clip level: {k}"); - assert_eq!(img.data[0], [0.3, 0.6, 0.4]); - // a white that cannot be right falls back to camera neutral, and nothing overflows - for bad in [[f32::NAN, 1.0, 1.0], [2.5e38, 0.74, 0.74], [1.0, 0.0, 1.0], [40.0, 1.0, 1.0]] { - let mut img = Rgb32f::filled(4, 4, [1.0; 3]); - img.data[0] = [1.0, 1.0, 0.5]; - reconstruct_with(&mut img, WB, 0.99, bad); - let q = balanced(img.get(1, 1)); - assert!((q[0] - q[1]).abs() < 1e-4 && (q[2] - q[1]).abs() < 1e-4, "{bad:?}: {q:?}"); - assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{bad:?}"); - } - } - - /// Red rises through its clip level across a sky whose green and blue clipped: the rebuilt colour changes - /// smoothly (no edge where red clips too) and arrives at the clipped neutral. - #[test] - fn rebuilt_colour_is_continuous_as_more_channels_clip() { - let blue = [1.0, 1.3, 1.9]; - let img0 = Rgb32f::from_fn(240, 40, |x, y| { - if y < 8 { - return sensor(blue.map(|v| v * 0.5)); // unclipped sky: the source of colour - } - let level = 0.6 + 2.2 * x as f32 / 239.0; // red at 0.24 to 1.12 of its clip level - sensor([level * WB[0] * 0.4, 1.6, 2.0]) - }); - let mut img = img0.clone(); - reconstruct(&mut img, WB, 0.99); - let row: Vec<[f32; 3]> = (0..240).map(|x| chroma(balanced(img.get(x, 30)))).collect(); - let step = row.windows(2).map(|w| (0..3).map(|c| (w[1][c] - w[0][c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); - assert!(step < 0.01, "largest chromaticity step between neighbours {step}"); - let end = row[239]; - assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 1e-3), "fully clipped end {end:?}"); - } - - /// A smooth pink ramp (`[0.9k, k, 0.8k]`, k from 0.8 to 1.35) whose green clips first, then red, then blue, - /// in `rows` of a `h`-row picture that is otherwise near black: the rebuilt middle row, and where green first - /// clips on it. - fn rebuilt_ramp(rows: std::ops::Range, h: usize) -> (Vec<[f32; 3]>, usize) { - let (w, k0, k1) = (1600usize, 0.8f32, 1.35f32); - let k = |x: usize| k0 + (k1 - k0) * x as f32 / (w - 1) as f32; - let img0 = Rgb32f::from_fn(w, h, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); - let mut img = img0.clone(); - reconstruct(&mut img, WB, 0.99); - let y = (rows.start + rows.end) / 2; - let row: Vec<[f32; 3]> = (0..w).map(|x| balanced(img.get(x, y))).collect(); - (row, (0..w).find(|&x| img0.get(x, y)[1] >= 0.99).unwrap_or(0)) - } - - /// No channel changes by more than `at_clip` (relative) where green first clips, nor by 3 % anywhere. - fn assert_continuous(row: &[[f32; 3]], first: usize, at_clip: f32, case: &str) { - // across the first clip the colour carries on as it was (the sensor's own colour, not a fade) - let (a, b) = (row[first - 1], row[first]); - assert!((0..3).all(|c| (b[c] / a[c] - 1.0).abs() < at_clip), "{case}, first clip: {a:?} then {b:?}"); - let worst = row.windows(2).map(|p| (0..3).map(|c| (p[1][c] / p[0][c] - 1.0).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); - // (a smooth ramp moves 0.04 % per pixel here; the rebuilt colour turns faster where it leaves the colour of - // the unclipped part for neutral, but never by a step) - assert!(worst < 0.03, "{case}: largest relative step between neighbours {worst}"); - } - - /// The rebuilt colour has no step anywhere along the ramp: not where green first clips (the neutral fade starts - /// from nothing there), nor where red does, nor where the last channel clips (the fade ends at the fully - /// clipped neutral). - #[test] - fn rebuilt_ramp_is_continuous_from_the_first_clip_to_the_last() { - let (row, first) = rebuilt_ramp(0..32, 32); - assert_continuous(&row, first, 0.002, "full ramp"); - } - - /// The same ramp as a thin line on near black has no reliable colour around (every pixel of it is on a strong - /// edge), so its clipped part falls back to the neutral guess. That guess only applies as far as the line runs - /// past its own unclipped part: no step where it first clips, however thin the line and however much dark - /// background surrounds it. - #[test] - fn thin_line_without_reliable_colour_is_continuous_where_it_clips() { - for height in [32usize, 512, 1024] { - for width in [1usize, 4, 16] { - let top = (height - width) / 2; - let (row, first) = rebuilt_ramp(top..top + width, height); - assert_continuous(&row, first, 0.002, &format!("{width} rows of {height}")); - // and well past the first clip the neutral guess has taken over (green as high as red and blue imply) - let far = row[1500]; - assert!(far[1] > 0.95 * far[2], "{width} rows of {height}: {far:?}"); - } - } - } - - /// Issue #523 (a river photo darkened by Highlights -75): a pale cyan-blue sky (`[1, 1.07, 1.2]` white-balanced) - /// brightening from left to right through the point where green clips (and later blue), above bright - /// yellow-green foliage, with thin dark branches standing in its lower half. Its rebuilt colour used to lean - /// green to teal: the foliage lent its colour to the sky (green rebuilt from it), with a step where green first - /// clips, and the sky kept its own pale colour where Lightroom shows white. - #[test] - fn pale_sky_by_dark_branches_and_foliage_is_not_tinted() { - let sky = [1.0, 1.07, 1.2]; - let (w, h) = (320usize, 96usize); - let k = |x: usize| 0.7 * (1.2 * x as f32 / (w - 1) as f32).exp2(); - let branches = [150usize, 200, 250]; - let on_branch = |x: usize| branches.iter().any(|&b| (b.saturating_sub(1)..=b + 2).contains(&x)); - let img0 = Rgb32f::from_fn(w, h, |x, y| { - sensor(if y >= 72 { - [0.5, 0.6, 0.22] - } else if y >= 40 && branches.iter().any(|&b| x == b || x == b + 1) { - [0.1, 0.09, 0.04] - } else { - sky.map(|v| v * k(x)) - }) - }); - let mut img = img0.clone(); - reconstruct(&mut img, WB, 0.99); - let at = |x: usize, y: usize| chroma(balanced(img.get(x, y))); - let first = (0..w).find(|&x| img0.get(x, 20)[1] >= 0.99).unwrap_or(0); - assert!(first > 0 && first < 150); - let diff = |a: [f32; 3], b: [f32; 3]| (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); - for y in [20, 50] { - // no step where green clips - let step = diff(at(first - 1, y), at(first, y)); - assert!(step < 0.001, "row {y}: step of {step} where green clips"); - for x in first..w { - // nothing but the sky's colour on its way to neutral (no green from the foliage)... - let off = off_the_way_to_neutral(at(x, y), chroma(sky)); - assert!(on_branch(x) || off < 0.005, "row {y}, x {x}: {:?} is off the sky's way to neutral by {off}", at(x, y)); - // ... the same beside the branches as above them ... - let beside = diff(at(x, y), at(x, 20)); - assert!(on_branch(x) || beside < 0.005, "row {y}, x {x}: {:?} beside a branch, {:?} above", at(x, y), at(x, 20)); - } - // ... and neutral well before the last channel clips (the sky is 0.7 stops past green's clip there) - let end = at(w - 1, y); - assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 0.002), "row {y}: {end:?} at the end"); - } - } - - #[test] - fn unclipped_pixels_are_left_exactly() { - let mut seed = 0x2545_f491_4f6c_dd1du64; - let mut rnd = move || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - (seed >> 40) as f32 / (1u64 << 24) as f32 - }; - for (w, h) in [(1, 1), (3, 1), (7, 5), (64, 48), (129, 77)] { - let noise: Vec = (0..w * h).map(|_| rnd()).collect(); - let img = Rgb32f::from_fn(w, h, |x, y| { - let t = ((x as f32 * 0.21).sin() + (y as f32 * 0.17).cos()) * 0.5; - let base = [0.5 + 0.4 * t, 0.45 - 0.2 * t, 0.3 + 0.1 * t]; - match noise[y * w + x] { - r if r < 0.1 => [1.0; 3], - r if r < 0.4 => [1.0, base[1] * 1.6, base[2]], - r if r > 0.995 => [0.0, 0.0, 0.00001], - _ => base, - } - }); - let mut out = img.clone(); - let n = reconstruct_with(&mut out, [2.1, 1.0, 1.6], 0.99, [1.02, 0.99, 1.05]); - let clipped = img.data.iter().filter(|p| p.iter().any(|&v| v >= 0.99)).count(); - assert_eq!(n, clipped); - for (a, b) in img.data.iter().zip(&out.data) { - if a.iter().all(|&v| v < 0.99) { - assert_eq!(a, b, "{w}×{h}"); - } else { - assert!(b.iter().all(|v| v.is_finite() && *v >= 0.0), "{w}×{h}: {b:?}"); - } - } - } - } - - #[test] - fn clip_neutral_makes_clipped_white_neutral() { - let wb = [2.0, 1.0, 1.5]; - let mut img = Rgb32f::filled(2, 1, [1.0, 1.0, 1.0]); - img.data[1] = [0.2, 0.3, 0.4]; - clip_neutral(&mut img, wb, 1.0); - let p = img.data[0]; - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - assert!((q[0] - q[1]).abs() < 1e-6 && (q[1] - q[2]).abs() < 1e-6); - assert_eq!(img.data[1], [0.2, 0.3, 0.4]); - } - - #[test] - fn reconstruct_restores_clipped_channel() { - // a warm gradient whose red channel clips at its bright end - let truth = Rgb32f::from_fn(256, 16, |x, _| { - let v = 0.3 + 1.0 * x as f32 / 255.0; - [v * 1.0, v * 0.6, v * 0.35] - }); - let mut img = truth.map(|p| p.map(|v| v.min(1.0))); - let first = (0..256).find(|&x| img.get(x, 8)[0] >= 0.999).unwrap(); - // near the unclipped gradient (within the reach of its colour) red is rebuilt from it - let err = |img: &Rgb32f| -> f32 { (first..first + 12).map(|x| (img.get(x, 8)[0] - truth.get(x, 8)[0]).abs()).sum() }; - let before = err(&img); - let n = reconstruct(&mut img, [1.0, 1.0, 1.0], 0.999); - assert!(n > 0); - let after = err(&img); - assert!(after < before * 0.2, "before {before} after {after}"); - // unclipped pixels untouched - assert_eq!(img.get(0, 0), truth.get(0, 0)); - assert_eq!(img.get(first - 1, 8), truth.get(first - 1, 8)); - } - - #[test] - fn fully_clipped_and_no_clipping() { - let mut img = Rgb32f::filled(4, 4, [1.0; 3]); - assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 16); - let p = img.data[0]; - assert!((p[0] * 2.0 - p[1]).abs() < 1e-5 && (p[2] * 1.5 - p[1]).abs() < 1e-5); - let mut img = Rgb32f::filled(4, 4, [0.5; 3]); - assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 0); - let mut empty = Rgb32f::new(0, 0); - assert_eq!(reconstruct(&mut empty, [1.0; 3], 0.99), 0); - } - - /// `cargo test --release -p lightcraft-raw --lib -- --ignored bench_reconstruct --nocapture` - #[test] - #[ignore] - fn bench_reconstruct() { - let img = Rgb32f::from_fn(6000, 4000, |x, y| { - let v = 0.4 + 0.8 * ((x as f32 * 0.003).sin() * (y as f32 * 0.002).cos()).abs(); - [v.min(1.0), (v * 0.7).min(1.0), (v * 0.5).min(1.0)] - }); - let ms = (0..3) - .map(|_| { - let mut i = img.clone(); - let t = std::time::Instant::now(); - reconstruct(&mut i, [2.0, 1.0, 1.5], 0.99); - t.elapsed().as_secs_f64() * 1e3 - }) - .fold(f64::MAX, f64::min); - eprintln!("reconstruct 24 MP: {ms:.0} ms"); - } - - /// A mask decides which channels are clipped (their values are lower bounds), whatever their values; the - /// unmarked pixels are left as they were; a mask of another size leaves clipping to the values. - #[test] - fn a_mask_marks_the_clipped_channels() { - let base = Rgb32f::from_fn(32, 32, |x, _| if x >= 16 { [0.2, 0.5, 0.25] } else { [0.5, 0.5, 0.5] }); - let mask: Vec = (0..32 * 32).map(|i| if i % 32 >= 16 { CLIPPED_R } else { 0 }).collect(); - let mut img = base.clone(); - assert_eq!(reconstruct_masked(&mut img, [1.0; 3], 0.99, [1.0; 3], &mask), 512); - for (p, b) in img.data.iter().zip(&base.data) { - assert!(p.iter().all(|v| v.is_finite()), "{p:?}"); - if b[0] == 0.5 { - assert_eq!(p, b); - } else { - // red rebuilt from at least its mean, the others kept - assert!(p[0] >= 0.2 && p[1] == 0.5 && p[2] == 0.25, "{p:?}"); - } - } - // red is rebuilt above its mean, towards the neighbours' neutral (as far as the pixel's own blue agrees) - assert!(img.get(24, 16)[0] > 0.3, "{:?}", img.get(24, 16)); - // the values alone see nothing clipped here, nor does a mask of another size - let mut plain = base.clone(); - assert_eq!(reconstruct(&mut plain, [1.0; 3], 0.99), 0); - assert_eq!(reconstruct_masked(&mut plain, [1.0; 3], 0.99, [1.0; 3], &mask[1..]), 0); - assert!(plain.data == base.data); - } -} +mod tests; diff --git a/crates/raw/src/highlight/tests.rs b/crates/raw/src/highlight/tests.rs new file mode 100644 index 000000000..3386e5d73 --- /dev/null +++ b/crates/raw/src/highlight/tests.rs @@ -0,0 +1,346 @@ +use super::*; + +/// Daylight-like multipliers: green clips first, red and blue keep rising. +const WB: [f32; 3] = [2.5, 1.0, 1.6]; + +fn balanced(p: [f32; 3]) -> [f32; 3] { + [p[0] * WB[0], p[1] * WB[1], p[2] * WB[2]] +} + +fn sensor(q: [f32; 3]) -> [f32; 3] { + [q[0] / WB[0], q[1] / WB[1], q[2] / WB[2]].map(|v| v.min(1.0)) +} + +fn chroma(q: [f32; 3]) -> [f32; 3] { + let s = q[0] + q[1] + q[2]; + q.map(|v| v / s) +} + +/// A pale blue sky whose green channel clipped, an unclipped strip of the same sky along the top, and a dark +/// branch with purple fringes on both sides standing in the clipped part (white-balanced sky colour returned). +fn branch_scene() -> (Rgb32f, [f32; 3]) { + let sky = [1.10, 1.25, 1.45]; + let img = Rgb32f::from_fn(192, 128, |x, y| { + sensor(if y < 24 { + sky.map(|v| v * 0.7) + } else if (90..100).contains(&x) && y >= 40 { + [0.06, 0.05, 0.03] + } else if (x == 89 || x == 100) && y >= 40 { + [0.75, 0.25, 0.96] + } else { + sky + }) + }); + (img, sky) +} + +/// How far chromaticity `got` lies off the way from `from` to neutral (largest channel difference to the +/// nearest point of that way). +fn off_the_way_to_neutral(got: [f32; 3], from: [f32; 3]) -> f32 { + let d: [f32; 3] = std::array::from_fn(|c| 1.0 / 3.0 - from[c]); + let len = d.iter().map(|v| v * v).sum::(); + let t = if len > 0.0 { ((0..3).map(|c| (got[c] - from[c]) * d[c]).sum::() / len).clamp(0.0, 1.0) } else { 0.0 }; + (0..3).map(|c| (got[c] - (from[c] + d[c] * t)).abs()).fold(0.0, f32::max) +} + +/// Issue #523: the clipped sky beside a branch took the colour of the branch's fringes and of the branch +/// itself (magenta, green), which any darkening of the highlights then showed. Beside the branch it is now the +/// clipped sky away from it: the sky's colour, on its way to neutral. +#[test] +fn fringes_and_dark_branches_do_not_tint_a_clipped_sky() { + let (mut img, sky) = branch_scene(); + assert!(reconstruct(&mut img, WB, 0.99) > 0); + let mut worst = 0f32; + for y in 40..128 { + let away = chroma(balanced(img.get(30, y))); + assert!(away == chroma(balanced(img.get(170, y))), "row {y}"); + let off = off_the_way_to_neutral(away, chroma(sky)); + assert!(off < 0.005, "row {y}: the clipped sky {away:?} is not the sky's colour on its way to neutral ({off})"); + for x in [84, 86, 88, 101, 103, 105] { + let got = chroma(balanced(img.get(x, y))); + worst = worst.max((0..3).map(|c| (got[c] - away[c]).abs()).fold(0.0, f32::max)); + } + } + assert!(worst < 0.01, "clipped sky beside the branch is off the sky away from it by {worst}"); +} + +/// A warm light with red and green clipped: left at their clip levels the pair reads red-magenta (the white +/// balance multiplies red's clip level by 2.5, green's by 1). +#[test] +fn two_clipped_channels_are_not_left_at_their_clip_levels() { + let light = [3.5, 2.6, 0.8]; + let mut img = Rgb32f::from_fn(64, 64, |x, y| { + let d = ((x as f32 - 32.0).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + if d < 20.0 { sensor(light) } else { [0.02; 3] } + }); + reconstruct(&mut img, WB, 0.99); + let q = balanced(img.get(32, 32)); + assert!((q[2] - 0.8).abs() < 0.1, "the unclipped channel is about kept: {q:?}"); + let ratio = q[1] / q[0]; + assert!((0.6..=1.05).contains(&ratio), "green / red {ratio} ({q:?}): not a warm white"); +} + +#[test] +fn fully_clipped_pixels_follow_the_colour_model_white() { + let white = [1.03, 0.98, 1.06]; + let mut img = Rgb32f::filled(8, 8, [1.0; 3]); + img.data[0] = [0.3, 0.6, 0.4]; + reconstruct_with(&mut img, WB, 0.99, white); + let q = balanced(img.get(4, 4)); + let k = q[1] / white[1]; + assert!((0..3).all(|c| (q[c] - white[c] * k).abs() < 1e-4 * k), "{q:?} is not along {white:?}"); + assert!(k >= 2.5 * 0.99 - 1e-4, "clipped neutral at least as bright as the highest clip level: {k}"); + assert_eq!(img.data[0], [0.3, 0.6, 0.4]); + // a white that cannot be right falls back to camera neutral, and nothing overflows + for bad in [[f32::NAN, 1.0, 1.0], [2.5e38, 0.74, 0.74], [1.0, 0.0, 1.0], [40.0, 1.0, 1.0]] { + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + img.data[0] = [1.0, 1.0, 0.5]; + reconstruct_with(&mut img, WB, 0.99, bad); + let q = balanced(img.get(1, 1)); + assert!((q[0] - q[1]).abs() < 1e-4 && (q[2] - q[1]).abs() < 1e-4, "{bad:?}: {q:?}"); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{bad:?}"); + } +} + +/// Red rises through its clip level across a sky whose green and blue clipped: the rebuilt colour changes +/// smoothly (no edge where red clips too) and arrives at the clipped neutral. +#[test] +fn rebuilt_colour_is_continuous_as_more_channels_clip() { + let blue = [1.0, 1.3, 1.9]; + let img0 = Rgb32f::from_fn(240, 40, |x, y| { + if y < 8 { + return sensor(blue.map(|v| v * 0.5)); // unclipped sky: the source of colour + } + let level = 0.6 + 2.2 * x as f32 / 239.0; // red at 0.24 to 1.12 of its clip level + sensor([level * WB[0] * 0.4, 1.6, 2.0]) + }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let row: Vec<[f32; 3]> = (0..240).map(|x| chroma(balanced(img.get(x, 30)))).collect(); + let step = row.windows(2).map(|w| (0..3).map(|c| (w[1][c] - w[0][c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); + assert!(step < 0.01, "largest chromaticity step between neighbours {step}"); + let end = row[239]; + assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 1e-3), "fully clipped end {end:?}"); +} + +/// A smooth pink ramp (`[0.9k, k, 0.8k]`, k from 0.8 to 1.35) whose green clips first, then red, then blue, +/// in `rows` of a `h`-row picture that is otherwise near black: the rebuilt middle row, and where green first +/// clips on it. +fn rebuilt_ramp(rows: std::ops::Range, h: usize) -> (Vec<[f32; 3]>, usize) { + let (w, k0, k1) = (1600usize, 0.8f32, 1.35f32); + let k = |x: usize| k0 + (k1 - k0) * x as f32 / (w - 1) as f32; + let img0 = Rgb32f::from_fn(w, h, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let y = (rows.start + rows.end) / 2; + let row: Vec<[f32; 3]> = (0..w).map(|x| balanced(img.get(x, y))).collect(); + (row, (0..w).find(|&x| img0.get(x, y)[1] >= 0.99).unwrap_or(0)) +} + +/// No channel changes by more than `at_clip` (relative) where green first clips, nor by 3 % anywhere. +fn assert_continuous(row: &[[f32; 3]], first: usize, at_clip: f32, case: &str) { + // across the first clip the colour carries on as it was (the sensor's own colour, not a fade) + let (a, b) = (row[first - 1], row[first]); + assert!((0..3).all(|c| (b[c] / a[c] - 1.0).abs() < at_clip), "{case}, first clip: {a:?} then {b:?}"); + let worst = row.windows(2).map(|p| (0..3).map(|c| (p[1][c] / p[0][c] - 1.0).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); + // (a smooth ramp moves 0.04 % per pixel here; the rebuilt colour turns faster where it leaves the colour of + // the unclipped part for neutral, but never by a step) + assert!(worst < 0.03, "{case}: largest relative step between neighbours {worst}"); +} + +/// The rebuilt colour has no step anywhere along the ramp: not where green first clips (the neutral fade starts +/// from nothing there), nor where red does, nor where the last channel clips (the fade ends at the fully +/// clipped neutral). +#[test] +fn rebuilt_ramp_is_continuous_from_the_first_clip_to_the_last() { + let (row, first) = rebuilt_ramp(0..32, 32); + assert_continuous(&row, first, 0.002, "full ramp"); +} + +/// The same ramp as a thin line on near black has no reliable colour around (every pixel of it is on a strong +/// edge), so its clipped part falls back to the neutral guess. That guess only applies as far as the line runs +/// past its own unclipped part: no step where it first clips, however thin the line and however much dark +/// background surrounds it. +#[test] +fn thin_line_without_reliable_colour_is_continuous_where_it_clips() { + for height in [32usize, 512, 1024] { + for width in [1usize, 4, 16] { + let top = (height - width) / 2; + let (row, first) = rebuilt_ramp(top..top + width, height); + assert_continuous(&row, first, 0.002, &format!("{width} rows of {height}")); + // and well past the first clip the neutral guess has taken over (green as high as red and blue imply) + let far = row[1500]; + assert!(far[1] > 0.95 * far[2], "{width} rows of {height}: {far:?}"); + } + } +} + +/// Issue #523 (a river photo darkened by Highlights -75): a pale cyan-blue sky (`[1, 1.07, 1.2]` white-balanced) +/// brightening from left to right through the point where green clips (and later blue), above bright +/// yellow-green foliage, with thin dark branches standing in its lower half. Its rebuilt colour used to lean +/// green to teal: the foliage lent its colour to the sky (green rebuilt from it), with a step where green first +/// clips, and the sky kept its own pale colour where Lightroom shows white. +#[test] +fn pale_sky_by_dark_branches_and_foliage_is_not_tinted() { + let sky = [1.0, 1.07, 1.2]; + let (w, h) = (320usize, 96usize); + let k = |x: usize| 0.7 * (1.2 * x as f32 / (w - 1) as f32).exp2(); + let branches = [150usize, 200, 250]; + let on_branch = |x: usize| branches.iter().any(|&b| (b.saturating_sub(1)..=b + 2).contains(&x)); + let img0 = Rgb32f::from_fn(w, h, |x, y| { + sensor(if y >= 72 { + [0.5, 0.6, 0.22] + } else if y >= 40 && branches.iter().any(|&b| x == b || x == b + 1) { + [0.1, 0.09, 0.04] + } else { + sky.map(|v| v * k(x)) + }) + }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let at = |x: usize, y: usize| chroma(balanced(img.get(x, y))); + let first = (0..w).find(|&x| img0.get(x, 20)[1] >= 0.99).unwrap_or(0); + assert!(first > 0 && first < 150); + let diff = |a: [f32; 3], b: [f32; 3]| (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); + for y in [20, 50] { + // no step where green clips + let step = diff(at(first - 1, y), at(first, y)); + assert!(step < 0.001, "row {y}: step of {step} where green clips"); + for x in first..w { + // nothing but the sky's colour on its way to neutral (no green from the foliage)... + let off = off_the_way_to_neutral(at(x, y), chroma(sky)); + assert!(on_branch(x) || off < 0.005, "row {y}, x {x}: {:?} is off the sky's way to neutral by {off}", at(x, y)); + // ... the same beside the branches as above them ... + let beside = diff(at(x, y), at(x, 20)); + assert!(on_branch(x) || beside < 0.005, "row {y}, x {x}: {:?} beside a branch, {:?} above", at(x, y), at(x, 20)); + } + // ... and neutral well before the last channel clips (the sky is 0.7 stops past green's clip there) + let end = at(w - 1, y); + assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 0.002), "row {y}: {end:?} at the end"); + } +} + +#[test] +fn unclipped_pixels_are_left_exactly() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 40) as f32 / (1u64 << 24) as f32 + }; + for (w, h) in [(1, 1), (3, 1), (7, 5), (64, 48), (129, 77)] { + let noise: Vec = (0..w * h).map(|_| rnd()).collect(); + let img = Rgb32f::from_fn(w, h, |x, y| { + let t = ((x as f32 * 0.21).sin() + (y as f32 * 0.17).cos()) * 0.5; + let base = [0.5 + 0.4 * t, 0.45 - 0.2 * t, 0.3 + 0.1 * t]; + match noise[y * w + x] { + r if r < 0.1 => [1.0; 3], + r if r < 0.4 => [1.0, base[1] * 1.6, base[2]], + r if r > 0.995 => [0.0, 0.0, 0.00001], + _ => base, + } + }); + let mut out = img.clone(); + let n = reconstruct_with(&mut out, [2.1, 1.0, 1.6], 0.99, [1.02, 0.99, 1.05]); + let clipped = img.data.iter().filter(|p| p.iter().any(|&v| v >= 0.99)).count(); + assert_eq!(n, clipped); + for (a, b) in img.data.iter().zip(&out.data) { + if a.iter().all(|&v| v < 0.99) { + assert_eq!(a, b, "{w}×{h}"); + } else { + assert!(b.iter().all(|v| v.is_finite() && *v >= 0.0), "{w}×{h}: {b:?}"); + } + } + } +} + +#[test] +fn clip_neutral_makes_clipped_white_neutral() { + let wb = [2.0, 1.0, 1.5]; + let mut img = Rgb32f::filled(2, 1, [1.0, 1.0, 1.0]); + img.data[1] = [0.2, 0.3, 0.4]; + clip_neutral(&mut img, wb, 1.0); + let p = img.data[0]; + let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + assert!((q[0] - q[1]).abs() < 1e-6 && (q[1] - q[2]).abs() < 1e-6); + assert_eq!(img.data[1], [0.2, 0.3, 0.4]); +} + +#[test] +fn reconstruct_restores_clipped_channel() { + // a warm gradient whose red channel clips at its bright end + let truth = Rgb32f::from_fn(256, 16, |x, _| { + let v = 0.3 + 1.0 * x as f32 / 255.0; + [v * 1.0, v * 0.6, v * 0.35] + }); + let mut img = truth.map(|p| p.map(|v| v.min(1.0))); + let first = (0..256).find(|&x| img.get(x, 8)[0] >= 0.999).unwrap(); + // near the unclipped gradient (within the reach of its colour) red is rebuilt from it + let err = |img: &Rgb32f| -> f32 { (first..first + 12).map(|x| (img.get(x, 8)[0] - truth.get(x, 8)[0]).abs()).sum() }; + let before = err(&img); + let n = reconstruct(&mut img, [1.0, 1.0, 1.0], 0.999); + assert!(n > 0); + let after = err(&img); + assert!(after < before * 0.2, "before {before} after {after}"); + // unclipped pixels untouched + assert_eq!(img.get(0, 0), truth.get(0, 0)); + assert_eq!(img.get(first - 1, 8), truth.get(first - 1, 8)); +} + +#[test] +fn fully_clipped_and_no_clipping() { + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 16); + let p = img.data[0]; + assert!((p[0] * 2.0 - p[1]).abs() < 1e-5 && (p[2] * 1.5 - p[1]).abs() < 1e-5); + let mut img = Rgb32f::filled(4, 4, [0.5; 3]); + assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 0); + let mut empty = Rgb32f::new(0, 0); + assert_eq!(reconstruct(&mut empty, [1.0; 3], 0.99), 0); +} + +/// `cargo test --release -p lightcraft-raw --lib -- --ignored bench_reconstruct --nocapture` +#[test] +#[ignore] +fn bench_reconstruct() { + let img = Rgb32f::from_fn(6000, 4000, |x, y| { + let v = 0.4 + 0.8 * ((x as f32 * 0.003).sin() * (y as f32 * 0.002).cos()).abs(); + [v.min(1.0), (v * 0.7).min(1.0), (v * 0.5).min(1.0)] + }); + let ms = (0..3) + .map(|_| { + let mut i = img.clone(); + let t = std::time::Instant::now(); + reconstruct(&mut i, [2.0, 1.0, 1.5], 0.99); + t.elapsed().as_secs_f64() * 1e3 + }) + .fold(f64::MAX, f64::min); + eprintln!("reconstruct 24 MP: {ms:.0} ms"); +} + +/// A mask decides which channels are clipped (their values are lower bounds), whatever their values; the +/// unmarked pixels are left as they were; a mask of another size leaves clipping to the values. +#[test] +fn a_mask_marks_the_clipped_channels() { + let base = Rgb32f::from_fn(32, 32, |x, _| if x >= 16 { [0.2, 0.5, 0.25] } else { [0.5, 0.5, 0.5] }); + let mask: Vec = (0..32 * 32).map(|i| if i % 32 >= 16 { CLIPPED_R } else { 0 }).collect(); + let mut img = base.clone(); + assert_eq!(reconstruct_masked(&mut img, [1.0; 3], 0.99, [1.0; 3], &mask), 512); + for (p, b) in img.data.iter().zip(&base.data) { + assert!(p.iter().all(|v| v.is_finite()), "{p:?}"); + if b[0] == 0.5 { + assert_eq!(p, b); + } else { + // red rebuilt from at least its mean, the others kept + assert!(p[0] >= 0.2 && p[1] == 0.5 && p[2] == 0.25, "{p:?}"); + } + } + // red is rebuilt above its mean, towards the neighbours' neutral (as far as the pixel's own blue agrees) + assert!(img.get(24, 16)[0] > 0.3, "{:?}", img.get(24, 16)); + // the values alone see nothing clipped here, nor does a mask of another size + let mut plain = base.clone(); + assert_eq!(reconstruct(&mut plain, [1.0; 3], 0.99), 0); + assert_eq!(reconstruct_masked(&mut plain, [1.0; 3], 0.99, [1.0; 3], &mask[1..]), 0); + assert!(plain.data == base.data); +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 011142b09..6626f10cb 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -894,620 +894,7 @@ impl RawImage { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cfa_helpers() { - let c = Cfa::bayer("RGGB").unwrap(); - assert!(c.is_bayer()); - assert_eq!(c.color_at(0, 0), 0); - assert_eq!(c.color_at(3, 3), 2); - assert_eq!(c.shifted(1, 0).name(), "GRBG"); - assert_eq!(c.shifted(1, 1).name(), "BGGR"); - assert!(Cfa::bayer("RGGX").is_none()); - for name in ["RGGB", "BGGR", "GRBG", "GBRG"] { - assert_eq!(Cfa::bayer_static(name).name(), name); - } - let x = Cfa::xtrans(); - assert!(!x.is_bayer()); - let greens = x.pattern.iter().filter(|&&p| p == 1).count(); - assert_eq!(greens, 20); - // every 3×3 block of the X-Trans layout has 5 greens - for by in 0..2 { - for bx in 0..2 { - let g = (0..9).filter(|i| x.color_at(bx * 3 + i % 3, by * 3 + i / 3) == 1).count(); - assert_eq!(g, 5); - } - } - } - - #[test] - fn a_picture_made_elsewhere_is_finished_like_develop_does() { - let (w, h) = (12usize, 10usize); - let raw = RawImage { - format: RawFormat::Dng, - width: w, - height: h, - cpp: 1, - data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), - cfa: Cfa::bayer("RGGB"), - bits: 12, - black: BlackLevel::uniform(64.0), - white: vec![1023.0], - active_area: Rect::new(2, 2, 8, 6), - crop: Rect::new(1, 1, 5, 4), - orientation: Orientation::Normal, - color: ColorData::default(), - wb_multipliers: None, - linearized: false, - pixel_aspect: 1.0, - opcodes: OpcodeLists::default(), - metadata: Metadata::default(), - lens_metadata: Default::default(), - }; - let direct = raw.develop(Method::Bilinear).unwrap(); - let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); - assert_eq!((direct.width, direct.height), (5, 4)); - assert_eq!(direct, by_hand); - // the crop is what develop keeps; no usable crop keeps everything - assert_eq!(raw.develop_crop(8, 6), Rect::new(1, 1, 5, 4)); - let mut whole = raw.clone(); - whole.crop = Rect::default(); - assert_eq!(whole.develop_crop(8, 6), Rect::new(0, 0, 8, 6)); - assert_eq!(whole.develop(Method::Bilinear).unwrap().width, 8); - // a crop that pokes out is clipped to the picture - whole.crop = Rect::new(6, 4, 10, 10); - assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); - - // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so - let mut tall = raw.clone(); - tall.pixel_aspect = 2.0; - let d = tall.develop(Method::Bilinear).unwrap(); - assert_eq!((d.width, d.height), (5, 8)); - assert_eq!(tall.info().developed_size(), (5, 8)); - assert_eq!(raw.info().developed_size(), (5, 4)); - assert!(!tall.can_bin(2)); - assert_eq!(stretched_height(1324, 2.0), 2648); - assert_eq!(stretched_height(1324, 1.0), 1324); - } - - /// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to - /// pass the bounds check (`x + width` wraps to 0) and then index out of bounds in - /// `normalized`. It must be a `Corrupt` error at validate time instead. - #[test] - fn an_active_area_that_wraps_past_the_image_is_corrupt() { - let (w, h) = (12usize, 10usize); - let mut raw = RawImage { - format: RawFormat::Dng, - width: w, - height: h, - cpp: 1, - data: RawData::U16(vec![100; w * h]), - cfa: Cfa::bayer("RGGB"), - bits: 12, - black: BlackLevel::uniform(64.0), - white: vec![1023.0], - active_area: Rect::new(usize::MAX, 0, 1, 1), - crop: Rect::default(), - orientation: Orientation::Normal, - color: ColorData::default(), - wb_multipliers: None, - linearized: false, - opcodes: OpcodeLists::default(), - metadata: Metadata::default(), - lens_metadata: Default::default(), - pixel_aspect: 1.0, - }; - assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image"); - assert!(raw.normalized().is_err()); - raw.active_area = Rect::new(0, 0, w + 1, h); - assert!(raw.validate().is_err(), "one past the last column is outside too"); - } - - #[test] - fn black_levels() { - let b = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![1.0, 2.0, 3.0, 4.0], delta_h: vec![0.5, 0.0], delta_v: vec![0.0, 10.0] }; - assert_eq!(b.at(0, 0, 0, 1), 1.5); - assert_eq!(b.at(1, 1, 0, 1), 14.0); - assert_eq!(b.at(3, 2, 0, 1), 2.0); - assert_eq!(b.mean(), 2.5); - assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0); - } - - // --- TIFF shells around a private raw block (thumbnail-only IFD0) --- - - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - - /// An uncompressed 8-bit RGB image IFD. - fn rgb_ifd(w: u32, h: u32) -> IfdBuilder { - let mut ifd = IfdBuilder::new(); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] }); - ifd - } - - fn exif_size(w: u32, h: u32) -> IfdBuilder { - IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h])) - } - - fn write(chain: &[IfdBuilder]) -> Vec { - TiffWriter::default().write(chain).unwrap() - } - - const SHELL_REASON: &str = "raw image in a private block"; - - /// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw). - #[test] - fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() { - let mut ifd0 = rgb_ifd(16, 12); - ifd0.add_sub_ifd(rgb_ifd(64, 48)); - let bytes = write(&[ifd0]); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; - assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); - // a sub-IFD that is not much bigger (a pyramid level) does not count - let mut ifd0 = rgb_ifd(16, 12); - ifd0.add_sub_ifd(rgb_ifd(32, 24)); - assert_eq!(probe(&write(&[ifd0])), None); - } - - /// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it. - #[test] - fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() { - let mut ifd0 = rgb_ifd(16, 12); - ifd0.set_child(t::EXIF_IFD, exif_size(400, 300)); - let mut bytes = write(&[ifd0.clone()]); - // no private block: a downsized copy that kept its old Exif size is an ordinary image - assert_eq!(probe(&bytes), None); - // 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is - bytes.extend(std::iter::repeat_n(7u8, 14_000)); - assert_eq!(probe(&bytes), None); - bytes.extend(std::iter::repeat_n(7u8, 2_000)); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; - assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); - // truncating it never panics - for n in (0..bytes.len()).step_by(37) { - let _ = probe(&bytes[..n]); - let _ = decode(&bytes[..n]); - } - } - - /// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger. - #[test] - fn ordinary_tiffs_are_not_shells() { - let padded = |chain: &[IfdBuilder]| { - let mut b = write(chain); - b.extend(std::iter::repeat_n(0u8, 100_000)); - b - }; - let mut full = rgb_ifd(400, 300); - full.set_child(t::EXIF_IFD, exif_size(400, 300)); - assert_eq!(probe(&padded(&[full])), None); - // the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it - let mut near = rgb_ifd(120, 90); - near.set_child(t::EXIF_IFD, exif_size(400, 300)); - assert_eq!(probe(&padded(&[near])), None); - // a multi-page TIFF whose first page is small - assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None); - // no dimensions at all - assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None); - // raw evidence without a CFA photometric: Pentax's Exif `CFAPattern` (uncompressed / PackBits PEFs) and - // Samsung's private compressions (here 32773, PackBits elsewhere: NX1, NX500) - let mut pef = rgb_ifd(400, 300); - pef.set(t::MAKE, Value::Ascii("PENTAX Corporation".into())); - let mut exif = exif_size(400, 300); - exif.set(vendor::EXIF_CFA_PATTERN, Value::Undefined(vec![0, 2, 0, 2, 0, 1, 1, 2])); - pef.set_child(t::EXIF_IFD, exif); - assert_eq!(probe(&padded(&[pef])), Some(RawFormat::Pef)); - let mut srw = rgb_ifd(400, 300); - srw.set(t::MAKE, Value::Ascii("SAMSUNG".into())); - srw.set(t::COMPRESSION, Value::Short(vec![32773])); - assert_eq!(probe(&padded(&[srw])), Some(RawFormat::Srw)); - // camera-authored or exported TIFFs preserving camera Make tags remain ordinary images - for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { - let mut cam = rgb_ifd(400, 300); - cam.set(t::MAKE, Value::Ascii(make.into())); - cam.set_child(t::EXIF_IFD, exif_size(400, 300)); - assert_eq!(probe(&padded(&[cam])), None, "{make} TIFF should not be probed as raw"); - } - } - - /// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to - /// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed - /// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model, - /// stays an ordinary image. - #[test] - fn sony_raws_without_a_cfa_ifd_are_still_raws() { - let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option| { - let mut ifd = rgb_ifd(400, 300); - ifd.set(t::MAKE, Value::Ascii("SONY".into())); - ifd.set(t::MODEL, Value::Ascii(model.into())); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); - ifd.set(t::COMPRESSION, Value::Short(vec![compression])); - if let Some(child) = sub_ifd { - ifd.set_child(t::SUB_IFDS, child); - } - probe(&write(&[ifd])) - }; - let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer - assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF"); - assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too"); - assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs"); - assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF"); - assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF"); - // TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702) - assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail"); - assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail"); - assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model"); - } - - // --- containers that are recognised but not decoded, with a preview --- - - #[test] - fn private_containers_are_recognised_by_their_magic() { - assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); - assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); - assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); - for f in [RawFormat::Crw, RawFormat::X3f] { - assert!(!f.is_supported()); - } - assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f"))); - // too short for the CIFF signature - assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); - } - - /// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw - /// container with a preview; a TIFF reader would call it malformed. - #[test] - fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() { - let jpeg: Vec = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat(); - let off = 8 + 2 + 24 + 4; - let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); - for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { - f.extend_from_slice(&tag.to_le_bytes()); - f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); - f.extend_from_slice(&v.to_le_bytes()); - } - f.extend_from_slice(&[0, 0, 0, 0]); - f.extend_from_slice(&jpeg); - assert_eq!(probe(&f), Some(RawFormat::OtherTiff)); - assert!(matches!(decode(&f), Err(RawError::Unsupported(_)))); - assert_eq!(embedded_preview(&f), Some(jpeg)); - // no pointer and no sub-IFDs: just a broken TIFF - let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]); - assert_eq!(probe(&g), None); - } - - /// A GoPro GPR (a DNG with VC-5 coded raw data, Compression 9) is refused by the full decode with - /// a reason that names the format, whatever its tile bytes hold; the header probe still - /// describes it (so it imports). - #[test] - fn gopro_vc5_dng_is_unsupported_with_a_clear_reason() { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); - ifd.set(t::COMPRESSION, Value::Short(vec![9])); - ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); - let bytes = write(&[ifd]); - assert_eq!(probe(&bytes), Some(RawFormat::Dng)); - let want = "DNG compression 9 (GoPro VC-5) is not decoded yet"; - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported from decode") }; - assert_eq!(why, want); - let info = probe_info(&bytes).expect("header probe describes the file"); - assert_eq!((info.width, info.height), (32, 16)); - } - - /// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default - /// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it. - fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec { - let linear = |subfile: u32, w: u32, h: u32, sample: u16| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); - let samples: Vec = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect(); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] }); - ifd - }; - let mut main = linear(0, w, h, 1000); - main.set(t::BLACK_LEVEL, Value::Short(vec![100])); - main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); - main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); - for (tag, v) in main_tags { - main.set(*tag, v.clone()); - } - let mut ifd0 = rgb_ifd(4, 2); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("DJI".into())); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.add_sub_ifd(main); - for &(cw, ch) in copies { - ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16)); - } - write(&[ifd0]) - } - - /// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the - /// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`. - #[test] - fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() { - let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]); - let full = decode(&file).unwrap(); - assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28))); - // a 12 px picture: the 16 px copy (its crop is 14 px long) - let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px"); - assert_eq!((small.width, small.height), (16, 8)); - assert_eq!(small.crop, Rect::new(1, 1, 14, 7)); - let RawData::U16(v) = &small.data else { panic!("16-bit samples") }; - assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples"); - assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black"); - assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's"); - // 20 px: the 32 px copy (28 px); 40 px: none covers it - assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32)); - assert!(decode_reduced(&file, 40).unwrap().is_none()); - // a different aspect ratio is not a copy of the image - assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none()); - // opcodes are in full-size pixels - let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]); - assert!(decode_reduced(&opcodes, 8).unwrap().is_none()); - // not a DNG - assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none()); - } - - /// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. - #[test] - fn private_compression_99_is_a_raw() { - let mut ifd = rgb_ifd(16, 12); - ifd.set(t::COMPRESSION, Value::Short(vec![99])); - assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); - } - - /// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's - /// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD - /// without the block, or an RGB image carrying it, stays an ordinary image. - #[test] - fn uncompressed_leaf_mos_is_a_raw() { - let mosaic = |leaf_data: bool| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] }); - if leaf_data { - ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); - } - ifd - }; - let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap(); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_)))); - assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_)))); - assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF"); - let mut rgb = rgb_ifd(16, 12); - rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); - assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block"); - } - - /// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767 - /// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there. - #[test] - fn hasselblad_make_with_sony_raw_coding_is_arw() { - let file = |make: &str, compression: u16, tone_curve: bool| { - let mut raw = IfdBuilder::new(); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![64])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![2])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); - raw.set(t::COMPRESSION, Value::Short(vec![compression])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - if tone_curve { - raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); - } - raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] }); - let mut ifd0 = rgb_ifd(4, 3); - ifd0.set(t::MAKE, Value::Ascii(make.into())); - ifd0.set(t::MODEL, Value::Ascii("Lunar".into())); - ifd0.add_sub_ifd(raw); - write(&[ifd0]) - }; - assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw)); - assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw)); - let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap(); - assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14)); - // a Hasselblad CFA image in another coding is not an ARW - assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw)); - assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); - } - - // --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- - - /// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. - fn cfa_samples() -> Vec { - (0..48u16).map(|i| 1000 + 100 * (i / 8) + i % 8).collect() - } - - /// A small raw in the layout of the Hasselblad 3FR/FFF family: IFD0 is a reduced RGB image with the - /// colour tags, a SubIFD holds the full-resolution CFA image (no `CFAPattern`, DNG-style levels and - /// crop). `strip` is the CFA image's data and `compression` its coding. - fn plain_cfa_tiff(compression: u16, strip: Vec, pattern: Option<[u8; 4]>) -> Vec { - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::COMPRESSION, Value::Short(vec![compression])); - raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); - raw.set(t::WHITE_LEVEL, Value::Long(vec![60000])); - raw.set(t::DEFAULT_CROP_ORIGIN, Value::Short(vec![2, 2])); - raw.set(t::DEFAULT_CROP_SIZE, Value::Short(vec![4, 2])); - if let Some(p) = pattern { - raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); - } - raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![strip] }); - let mut ifd0 = rgb_ifd(4, 3); - ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(5, 10), (-1, 10), (0, 10), (-5, 10), (12, 10), (3, 10), (-1, 10), (2, 10), (6, 10)])); - ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)])); - ifd0.add_sub_ifd(raw); - write(&[ifd0]) - } - - fn le_words(v: &[u16]) -> Vec { - v.iter().flat_map(|x| x.to_le_bytes()).collect() - } - - /// Known answer: samples, black and white level, crop and colour come from the file's own tags; the - /// layout it does not state is RGGB. - #[test] - fn uncompressed_cfa_tiff_without_a_dng_version_decodes() { - let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), None); - assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); - let r = decode(&bytes).unwrap(); - assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::CfaTiff, 8, 6, 16)); - assert_eq!(r.data, RawData::U16(cfa_samples())); - assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); - assert_eq!((r.black.mean(), r.white.clone()), (256.0, vec![60000.0])); - assert_eq!(r.crop, Rect::new(2, 2, 4, 2)); - assert!(color::has_matrix(&r.color)); - assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.6])); - // headers only: the same description without samples - let i = probe_info(&bytes).unwrap(); - assert_eq!((i.format, i.cfa.as_ref().map(Cfa::name), i.crop), (RawFormat::CfaTiff, Some("RGGB".to_string()), Rect::new(2, 2, 4, 2))); - } - - /// Without DNG's white-balance tag the file is left alone (a Kodak or Sinar TIFF with a CFA IFD, say). - #[test] - fn cfa_tiff_without_a_white_balance_tag_is_not_claimed() { - let mut raw = IfdBuilder::new(); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] }); - let mut ifd0 = rgb_ifd(4, 3); - ifd0.add_sub_ifd(raw); - assert_eq!(probe(&write(&[ifd0])), Some(RawFormat::OtherTiff)); - } - - /// A stated `CFAPattern` wins over the RGGB default. - #[test] - fn cfa_tiff_keeps_a_stated_pattern() { - let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), Some([2, 1, 1, 0])); - assert_eq!(decode(&bytes).unwrap().cfa.unwrap().name(), "BGGR"); - } - - /// The lossless-JPEG coding of the same layout goes through the lossless decoder (one strip for the - /// whole image, as the 3FR/FFF files have it). - #[test] - fn lossless_jpeg_cfa_tiff_decodes() { - let strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); - let bytes = plain_cfa_tiff(7, strip, None); - assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); - let r = decode(&bytes).unwrap(); - assert_eq!(r.data, RawData::U16(cfa_samples())); - assert_eq!(r.cfa.unwrap().name(), "RGGB"); - } - - /// Hasselblad's own lossless-JPEG coding (selection value 8, as in FFF and most compressed 3FR files) of the - /// same layout decodes: the file is read like the uncompressed one. - #[test] - fn selection_value_8_cfa_tiff_decodes() { - let strip = ljpeg::tests::encode_hasselblad(&cfa_samples(), 8, 6); - let bytes = plain_cfa_tiff(7, strip, None); - assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); - let r = decode(&bytes).unwrap(); - assert_eq!((r.format, r.width, r.height), (RawFormat::CfaTiff, 8, 6)); - assert_eq!(r.data, RawData::U16(cfa_samples())); - assert_eq!(r.cfa.unwrap().name(), "RGGB"); - assert_eq!(probe_info(&bytes).unwrap().format, RawFormat::CfaTiff); - } - - /// A lossless-JPEG scan whose predictor selection value is neither 0 to 7 (T.81) nor 8 (Hasselblad) is not a - /// coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, and keeps the - /// stand-in preview an unsupported container gets. - #[test] - fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() { - let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); - let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); - let ss = sos + 2 + 2 + 1 + 2; - assert_eq!(strip[ss], 1, "selection value byte"); - strip[ss] = 9; - assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); - let bytes = plain_cfa_tiff(7, strip, None); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - assert!(!RawFormat::OtherTiff.is_supported()); - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; - assert!(why.contains("lossless JPEG") && why.contains("selection value 9"), "{why}"); - } - - /// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader. - #[test] - fn cfa_tiff_with_another_coding_is_not_claimed() { - let bytes = plain_cfa_tiff(8, vec![0; 96], None); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - } - - /// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another - /// Samsung file stays with the Samsung format. - #[test] - fn samsung_make_with_a_pentax_maker_note_is_pef() { - let with_note = |note: &[u8], cfa: bool| { - let mut ifd = rgb_ifd(16, 12); - if cfa { - ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - } - ifd.set(t::MAKE, Value::Ascii("SAMSUNG TECHWIN".into())); - let mut exif = IfdBuilder::new(); - exif.set(t::MAKER_NOTE, Value::Undefined(note.to_vec())); - ifd.set_child(t::EXIF_IFD, exif); - write(&[ifd]) - }; - assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); - assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); - assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", true)), Some(RawFormat::Srw)); - // without raw evidence (an RGB image from such a body) neither reader claims it (#281) - assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", false)), None); - assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", false)), None); - } - - #[test] - fn probe_rejects_junk() { - assert_eq!(probe(b""), None); - assert_eq!(probe(b"hello world, not a raw"), None); - assert_eq!(probe(b"\0\0\0\x18ftypcrx \0\0\0\x01"), Some(RawFormat::Cr3)); - assert_eq!(probe(b"FUJIFILMCCD-RAW 0201"), Some(RawFormat::Raf)); - assert!(decode(b"FUJIFILMCCD-RAW 0201").is_err()); - assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); - } -} +mod tests; #[cfg(test)] mod checked_arithmetic_tests { diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index a3240eb20..922a9fb64 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -1004,316 +1004,4 @@ pub fn encode(data: &[u16], width: usize, height: usize, components: usize, prec } #[cfg(test)] -pub(crate) mod tests { - use super::*; - - /// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order. - pub(crate) fn fixture_420() -> Vec { - fixture_subsampled(4, 4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2]) - } - - /// A 16-bit three-component frame of `width × height` pixels with the given luma `sampling` (`0x21` / `0x22`) - /// and one shared 5-bit Huffman table, whose differences are written as given (MCU order). - pub(crate) fn fixture_subsampled(width: u8, height: u8, sampling: u8, differences: &[i32]) -> Vec { - let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, width, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0]; - out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]); - let mut counts = [0; 16]; - counts[4] = 17; - out.extend_from_slice(&counts); - out.extend(0..=16); - out.extend_from_slice(&[0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 1, 0, 0]); - let mut bw = BitWriter { out, acc: 0, n: 0 }; - for &d in differences { - let s = ssss_of(d); - bw.put(s as u32, 5); - if s > 0 && s < 16 { - let v = if d < 0 { d - 1 } else { d }; - bw.put(v as u32 & ((1 << s) - 1), s as u32); - } - } - bw.flush(); - bw.out.extend_from_slice(&[0xff, 0xd9]); - bw.out - } - - #[test] - fn horizontal_only_subsampling_preserves_rows_and_chroma() { - // Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU. - let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); - let frame = decode_subsampled(&enc, 16, Prediction::Geometric).unwrap(); - assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1)); - assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]); - assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); - assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); - assert!(decode(&enc, 24).is_err()); - assert!(matches!(decode_subsampled(&enc, 15, Prediction::Geometric), Err(RawError::Limit(_)))); - assert!(decode_subsampled(&enc[..enc.len() - 6], 16, Prediction::Geometric).is_err()); - } - - #[test] - fn subsampled_mcu_order_preserves_each_plane() { - let enc = fixture_420(); - let frame = decode_subsampled(&enc, 24, Prediction::Geometric).unwrap(); - assert_eq!((frame.width, frame.height), (4, 4)); - assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]); - assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); - assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); - assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling. - assert!(matches!(decode_subsampled(&enc, 23, Prediction::Geometric), Err(RawError::Limit(_)))); - assert!(decode_subsampled(&enc[..enc.len() - 6], 24, Prediction::Geometric).is_err()); - let mut bad = enc.clone(); - bad[10] = 3; // odd frame width - assert!(frame_info_subsampled(&bad).is_err()); - let sos = bad.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); - bad = enc.clone(); - bad[sos + 11] = 2; // unsupported predictor - assert!(frame_info_subsampled(&bad).is_err()); - } - - #[test] - fn sequential_prediction_chains_each_component_in_decoding_order() { - // Canon sRAW / mRAW: the same stream as `subsampled_mcu_order_preserves_each_plane`, but each component - // continues from its previous sample in decoding order (the second luma row of an MCU follows the first), - // and a new MCU row starts from the first sample of the previous one. Worked out by hand from init 32768. - let enc = fixture_420(); - let frame = decode_subsampled(&enc, 24, Prediction::Sequential).unwrap(); - assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 4, 2)); - assert_eq!(frame.planes[0], [1000, 1001, 2003, 2004, 2001, 2002, 2005, 2006, 1100, 1101, 2103, 2104, 2101, 2102, 2105, 2106]); - assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); - assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); - // 4:2:2 has one luma row per MCU row, so both predictions agree - let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); - assert_eq!( - decode_subsampled(&enc, 16, Prediction::Sequential).unwrap().planes, - decode_subsampled(&enc, 16, Prediction::Geometric).unwrap().planes - ); - } - - fn noise(n: usize, bits: u32, seed: u64) -> Vec { - let mut s = seed; - (0..n) - .map(|i| { - s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let smooth = ((i % 97) as u32 * 37) % (1 << bits); - let jitter = ((s >> 33) as u32) % 64; - ((smooth + jitter) % (1 << bits)) as u16 - }) - .collect() - } - - #[test] - fn roundtrip_all_predictors_components_precisions() { - for pred in 1..=7u8 { - for comps in 1..=4usize { - for bits in [8u8, 12, 14, 16] { - let (w, h) = (13, 7); - let data = noise(w * h * comps, bits as u32, (pred as u64) * 31 + comps as u64); - let enc = encode(&data, w, h, comps, bits, pred, 0); - let f = decode(&enc, usize::MAX).unwrap(); - assert_eq!((f.width, f.height, f.components, f.precision, f.predictor), (w, h, comps, bits, pred)); - assert_eq!(f.data, data, "pred {pred} comps {comps} bits {bits}"); - } - } - } - } - - #[test] - fn roundtrip_extremes_and_restarts() { - // alternating 0 / 65535 forces SSSS = 16 differences - let data: Vec = (0..64).map(|i| if i % 2 == 0 { 0 } else { 65535 }).collect(); - for pred in 1..=7 { - let enc = encode(&data, 8, 8, 1, 16, pred, 0); - assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); - } - let data = noise(20 * 9 * 2, 12, 5); - for rst in [1usize, 7, 20, 40] { - let enc = encode(&data, 20, 9, 2, 12, 6, rst); - assert_eq!(decode(&enc, usize::MAX).unwrap().data, data, "restart {rst}"); - } - } - - #[test] - fn constant_image_single_symbol_table() { - let data = vec![1234u16; 16 * 4]; - let enc = encode(&data, 16, 4, 1, 14, 1, 0); - assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); - } - - #[test] - fn rejects_bad_streams() { - assert!(decode(&[], 100).is_err()); - assert!(decode(&[0xff, 0xd8, 0xff, 0xd9], 100).is_err()); - let data = noise(64, 12, 1); - let enc = encode(&data, 8, 8, 1, 12, 1, 0); - assert!(matches!(decode(&enc, 10), Err(RawError::Limit(_)))); - for n in 0..enc.len() { - let _ = decode(&enc[..n], usize::MAX); - } - let mut bad = enc.clone(); - for i in (20..bad.len()).step_by(3) { - bad[i] ^= 0x5a; - } - let _ = decode(&bad, usize::MAX); - } - - // --- selection value 8 (Hasselblad 3FR / FFF) --- - - /// SOI, SOF3 (16 bits, `height` x `width`, one component), a DHT and an SOS with selection value 8, then - /// `entropy` and EOI. - fn hasselblad_stream(width: u16, height: u16, counts: &[u8; 16], values: &[u8], entropy: &[u8]) -> Vec { - let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 11, 16]; - out.extend_from_slice(&height.to_be_bytes()); - out.extend_from_slice(&width.to_be_bytes()); - out.extend_from_slice(&[1, 0, 0x11, 0]); - out.extend_from_slice(&[0xff, 0xc4]); - out.extend_from_slice(&((2 + 17 + values.len()) as u16).to_be_bytes()); - out.push(0); - out.extend_from_slice(counts); - out.extend_from_slice(values); - out.extend_from_slice(&[0xff, 0xda, 0, 8, 1, 0, 0, 8, 0, 0]); - out.extend_from_slice(entropy); - out.extend_from_slice(&[0xff, 0xd9]); - out - } - - /// Codes: SSSS 0 = `00`, 1 = `01`, 2 = `100`, 15 = `101`, 16 = `110`. - const HAND_COUNTS: [u8; 16] = [0, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - const HAND_VALUES: [u8; 5] = [0, 1, 2, 15, 16]; - - /// A 4 x 2 frame written out bit by bit: - /// - row 0, pair 1: codes `110` `110` (SSSS 16 twice), then 16 bits each (`FFFF`, `7FFF`) that do not count: - /// both samples are 32768 - 32768 = 0; - /// - row 0, pair 2: codes `01` `100`, bits `1` (+1) and `01` (-2): 0 + 1 = 1 and 0 - 2 = 65534 (modulo 2^16); - /// - row 1, pair 1: codes `00` `01`, bit `0` (-1): both predictors start again at 32768, so 32768 and 32767; - /// - row 1, pair 2: codes `100` `00`, bits `11` (+3): 32771 and 32767. - /// - /// 58 bits, padded with zeros to two 32-bit words `DBFFFDFF` `FD9450C0`, stored little-endian. The `FF FD` - /// they start with would be a marker to a T.81 reader: this coding has no byte stuffing. - const HAND_WORDS: [u8; 8] = [0xff, 0xfd, 0xff, 0xdb, 0xc0, 0x50, 0x94, 0xfd]; - const HAND_SAMPLES: [u16; 8] = [0, 0, 1, 65534, 32768, 32767, 32771, 32767]; - - #[test] - fn selection_value_8_known_answer() { - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - assert_eq!(frame_info(&enc).unwrap(), (4, 2, 1, 16)); - let f = decode(&enc, 8).unwrap(); - assert_eq!((f.width, f.height, f.components, f.predictor), (4, 2, 1, 8)); - assert_eq!(f.data, HAND_SAMPLES); - assert_eq!(decode_hasselblad(&enc, 8).unwrap().data, HAND_SAMPLES); - assert!(matches!(decode(&enc, 7), Err(RawError::Limit(_)))); - } - - /// The same bits in plain byte order (not 32-bit little-endian words) are a different stream. - #[test] - fn selection_value_8_reads_little_endian_words() { - let mut plain = HAND_WORDS; - plain[..4].reverse(); - plain[4..].reverse(); - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &plain); - assert_ne!(decode(&enc, 8).map(|f| f.data).ok(), Some(HAND_SAMPLES.to_vec())); - } - - #[test] - fn selection_value_8_rejects_bad_streams() { - // one word is not enough for the second row - let short = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS[..4]); - assert!(decode(&short, 8).is_err()); - // a category above 16 - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &[0, 1, 2, 15, 17], &HAND_WORDS); - assert!(decode(&enc, 8).is_err()); - // layouts never seen with selection value 8 are not guessed at - let mut odd = hasselblad_stream(3, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - assert!(matches!(frame_info(&odd), Err(RawError::Unsupported(_)))); - assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); - odd = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - odd[6] = 14; // precision - assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); - // selection values above 8 stay unknown - let mut nine = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - let sos = nine.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); - nine[sos + 7] = 9; - assert!(matches!(decode(&nine, 8), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); - // every prefix fails cleanly - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - for n in 0..enc.len() { - let _ = decode(&enc[..n], 8); - } - } - - /// Test-only writer of the selection-value-8 coding (the inverse of the rule in [`decode_hasselblad`]), with - /// a 5-bit code for every category. Category-16 differences carry `FFFF` as their ignored bits. - pub(crate) fn encode_hasselblad(data: &[u16], width: usize, height: usize) -> Vec { - assert!(width.is_multiple_of(2) && data.len() == width * height); - let mut bits: Vec = vec![]; - let put = |bits: &mut Vec, v: u32, n: u32| { - for i in (0..n).rev() { - bits.push((v >> i) & 1 == 1); - } - }; - for row in data.chunks_exact(width) { - let mut last = [32768i32, 32768]; - for pair in row.as_chunks::<2>().0 { - let mut d = [0i32; 2]; - for k in 0..2 { - let mut v = (pair[k] as i32 - last[k]) & 0xffff; - if v >= 32768 { - v -= 65536; - } - d[k] = v; - last[k] = pair[k] as i32; - } - let s = [ssss_of(d[0]), ssss_of(d[1])]; - put(&mut bits, s[0] as u32, 5); - put(&mut bits, s[1] as u32, 5); - for k in 0..2 { - match s[k] { - 0 => {} - 16 => put(&mut bits, 0xffff, 16), - n => { - let v = if d[k] < 0 { d[k] - 1 } else { d[k] }; - put(&mut bits, v as u32 & ((1 << n) - 1), n as u32); - } - } - } - } - } - let mut entropy = vec![]; - for word in bits.chunks(32) { - let w = word.iter().enumerate().fold(0u32, |a, (i, &b)| a | ((b as u32) << (31 - i))); - entropy.extend_from_slice(&w.to_le_bytes()); - } - let mut counts = [0u8; 16]; - counts[4] = 17; - let values: Vec = (0..=16).collect(); - hasselblad_stream(width as u16, height as u16, &counts, &values, &entropy) - } - - #[test] - fn selection_value_8_round_trips_through_the_test_writer() { - // extremes force SSSS 16 and wrap-around; noise exercises every other category - let mut data = noise(10 * 7, 16, 9); - data[0] = 0; - data[1] = 65535; - data[2] = 65535; - data[3] = 0; - let enc = encode_hasselblad(&data, 10, 7); - assert_eq!(decode(&enc, 70).unwrap().data, data); - } - - #[test] - fn code_lengths_are_limited_to_16() { - // Fibonacci-like frequencies produce very long codes before adjustment - let mut f = [0u32; 17]; - let (mut a, mut b) = (1u32, 1u32); - for x in f.iter_mut() { - *x = a; - let c = a.saturating_add(b); - a = b; - b = c; - } - let (counts, values) = code_lengths(&f); - assert_eq!(counts.iter().map(|&c| c as usize).sum::(), 17); - assert_eq!(values.len(), 17); - assert!(Huffman::new(&counts, &values).is_ok()); - } -} +pub(crate) mod tests; diff --git a/crates/raw/src/ljpeg/tests.rs b/crates/raw/src/ljpeg/tests.rs new file mode 100644 index 000000000..c5c6ccf25 --- /dev/null +++ b/crates/raw/src/ljpeg/tests.rs @@ -0,0 +1,311 @@ +use super::*; + +/// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order. +pub(crate) fn fixture_420() -> Vec { + fixture_subsampled(4, 4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2]) +} + +/// A 16-bit three-component frame of `width × height` pixels with the given luma `sampling` (`0x21` / `0x22`) +/// and one shared 5-bit Huffman table, whose differences are written as given (MCU order). +pub(crate) fn fixture_subsampled(width: u8, height: u8, sampling: u8, differences: &[i32]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, width, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0]; + out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]); + let mut counts = [0; 16]; + counts[4] = 17; + out.extend_from_slice(&counts); + out.extend(0..=16); + out.extend_from_slice(&[0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 1, 0, 0]); + let mut bw = BitWriter { out, acc: 0, n: 0 }; + for &d in differences { + let s = ssss_of(d); + bw.put(s as u32, 5); + if s > 0 && s < 16 { + let v = if d < 0 { d - 1 } else { d }; + bw.put(v as u32 & ((1 << s) - 1), s as u32); + } + } + bw.flush(); + bw.out.extend_from_slice(&[0xff, 0xd9]); + bw.out +} + +#[test] +fn horizontal_only_subsampling_preserves_rows_and_chroma() { + // Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU. + let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + let frame = decode_subsampled(&enc, 16, Prediction::Geometric).unwrap(); + assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 24).is_err()); + assert!(matches!(decode_subsampled(&enc, 15, Prediction::Geometric), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 16, Prediction::Geometric).is_err()); +} + +#[test] +fn subsampled_mcu_order_preserves_each_plane() { + let enc = fixture_420(); + let frame = decode_subsampled(&enc, 24, Prediction::Geometric).unwrap(); + assert_eq!((frame.width, frame.height), (4, 4)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling. + assert!(matches!(decode_subsampled(&enc, 23, Prediction::Geometric), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 24, Prediction::Geometric).is_err()); + let mut bad = enc.clone(); + bad[10] = 3; // odd frame width + assert!(frame_info_subsampled(&bad).is_err()); + let sos = bad.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + bad = enc.clone(); + bad[sos + 11] = 2; // unsupported predictor + assert!(frame_info_subsampled(&bad).is_err()); +} + +#[test] +fn sequential_prediction_chains_each_component_in_decoding_order() { + // Canon sRAW / mRAW: the same stream as `subsampled_mcu_order_preserves_each_plane`, but each component + // continues from its previous sample in decoding order (the second luma row of an MCU follows the first), + // and a new MCU row starts from the first sample of the previous one. Worked out by hand from init 32768. + let enc = fixture_420(); + let frame = decode_subsampled(&enc, 24, Prediction::Sequential).unwrap(); + assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 4, 2)); + assert_eq!(frame.planes[0], [1000, 1001, 2003, 2004, 2001, 2002, 2005, 2006, 1100, 1101, 2103, 2104, 2101, 2102, 2105, 2106]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + // 4:2:2 has one luma row per MCU row, so both predictions agree + let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + assert_eq!( + decode_subsampled(&enc, 16, Prediction::Sequential).unwrap().planes, + decode_subsampled(&enc, 16, Prediction::Geometric).unwrap().planes + ); +} + +fn noise(n: usize, bits: u32, seed: u64) -> Vec { + let mut s = seed; + (0..n) + .map(|i| { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let smooth = ((i % 97) as u32 * 37) % (1 << bits); + let jitter = ((s >> 33) as u32) % 64; + ((smooth + jitter) % (1 << bits)) as u16 + }) + .collect() +} + +#[test] +fn roundtrip_all_predictors_components_precisions() { + for pred in 1..=7u8 { + for comps in 1..=4usize { + for bits in [8u8, 12, 14, 16] { + let (w, h) = (13, 7); + let data = noise(w * h * comps, bits as u32, (pred as u64) * 31 + comps as u64); + let enc = encode(&data, w, h, comps, bits, pred, 0); + let f = decode(&enc, usize::MAX).unwrap(); + assert_eq!((f.width, f.height, f.components, f.precision, f.predictor), (w, h, comps, bits, pred)); + assert_eq!(f.data, data, "pred {pred} comps {comps} bits {bits}"); + } + } + } +} + +#[test] +fn roundtrip_extremes_and_restarts() { + // alternating 0 / 65535 forces SSSS = 16 differences + let data: Vec = (0..64).map(|i| if i % 2 == 0 { 0 } else { 65535 }).collect(); + for pred in 1..=7 { + let enc = encode(&data, 8, 8, 1, 16, pred, 0); + assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); + } + let data = noise(20 * 9 * 2, 12, 5); + for rst in [1usize, 7, 20, 40] { + let enc = encode(&data, 20, 9, 2, 12, 6, rst); + assert_eq!(decode(&enc, usize::MAX).unwrap().data, data, "restart {rst}"); + } +} + +#[test] +fn constant_image_single_symbol_table() { + let data = vec![1234u16; 16 * 4]; + let enc = encode(&data, 16, 4, 1, 14, 1, 0); + assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); +} + +#[test] +fn rejects_bad_streams() { + assert!(decode(&[], 100).is_err()); + assert!(decode(&[0xff, 0xd8, 0xff, 0xd9], 100).is_err()); + let data = noise(64, 12, 1); + let enc = encode(&data, 8, 8, 1, 12, 1, 0); + assert!(matches!(decode(&enc, 10), Err(RawError::Limit(_)))); + for n in 0..enc.len() { + let _ = decode(&enc[..n], usize::MAX); + } + let mut bad = enc.clone(); + for i in (20..bad.len()).step_by(3) { + bad[i] ^= 0x5a; + } + let _ = decode(&bad, usize::MAX); +} + +// --- selection value 8 (Hasselblad 3FR / FFF) --- + +/// SOI, SOF3 (16 bits, `height` x `width`, one component), a DHT and an SOS with selection value 8, then +/// `entropy` and EOI. +fn hasselblad_stream(width: u16, height: u16, counts: &[u8; 16], values: &[u8], entropy: &[u8]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 11, 16]; + out.extend_from_slice(&height.to_be_bytes()); + out.extend_from_slice(&width.to_be_bytes()); + out.extend_from_slice(&[1, 0, 0x11, 0]); + out.extend_from_slice(&[0xff, 0xc4]); + out.extend_from_slice(&((2 + 17 + values.len()) as u16).to_be_bytes()); + out.push(0); + out.extend_from_slice(counts); + out.extend_from_slice(values); + out.extend_from_slice(&[0xff, 0xda, 0, 8, 1, 0, 0, 8, 0, 0]); + out.extend_from_slice(entropy); + out.extend_from_slice(&[0xff, 0xd9]); + out +} + +/// Codes: SSSS 0 = `00`, 1 = `01`, 2 = `100`, 15 = `101`, 16 = `110`. +const HAND_COUNTS: [u8; 16] = [0, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +const HAND_VALUES: [u8; 5] = [0, 1, 2, 15, 16]; + +/// A 4 x 2 frame written out bit by bit: +/// - row 0, pair 1: codes `110` `110` (SSSS 16 twice), then 16 bits each (`FFFF`, `7FFF`) that do not count: +/// both samples are 32768 - 32768 = 0; +/// - row 0, pair 2: codes `01` `100`, bits `1` (+1) and `01` (-2): 0 + 1 = 1 and 0 - 2 = 65534 (modulo 2^16); +/// - row 1, pair 1: codes `00` `01`, bit `0` (-1): both predictors start again at 32768, so 32768 and 32767; +/// - row 1, pair 2: codes `100` `00`, bits `11` (+3): 32771 and 32767. +/// +/// 58 bits, padded with zeros to two 32-bit words `DBFFFDFF` `FD9450C0`, stored little-endian. The `FF FD` +/// they start with would be a marker to a T.81 reader: this coding has no byte stuffing. +const HAND_WORDS: [u8; 8] = [0xff, 0xfd, 0xff, 0xdb, 0xc0, 0x50, 0x94, 0xfd]; +const HAND_SAMPLES: [u16; 8] = [0, 0, 1, 65534, 32768, 32767, 32771, 32767]; + +#[test] +fn selection_value_8_known_answer() { + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + assert_eq!(frame_info(&enc).unwrap(), (4, 2, 1, 16)); + let f = decode(&enc, 8).unwrap(); + assert_eq!((f.width, f.height, f.components, f.predictor), (4, 2, 1, 8)); + assert_eq!(f.data, HAND_SAMPLES); + assert_eq!(decode_hasselblad(&enc, 8).unwrap().data, HAND_SAMPLES); + assert!(matches!(decode(&enc, 7), Err(RawError::Limit(_)))); +} + +/// The same bits in plain byte order (not 32-bit little-endian words) are a different stream. +#[test] +fn selection_value_8_reads_little_endian_words() { + let mut plain = HAND_WORDS; + plain[..4].reverse(); + plain[4..].reverse(); + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &plain); + assert_ne!(decode(&enc, 8).map(|f| f.data).ok(), Some(HAND_SAMPLES.to_vec())); +} + +#[test] +fn selection_value_8_rejects_bad_streams() { + // one word is not enough for the second row + let short = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS[..4]); + assert!(decode(&short, 8).is_err()); + // a category above 16 + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &[0, 1, 2, 15, 17], &HAND_WORDS); + assert!(decode(&enc, 8).is_err()); + // layouts never seen with selection value 8 are not guessed at + let mut odd = hasselblad_stream(3, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + assert!(matches!(frame_info(&odd), Err(RawError::Unsupported(_)))); + assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); + odd = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + odd[6] = 14; // precision + assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); + // selection values above 8 stay unknown + let mut nine = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + let sos = nine.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + nine[sos + 7] = 9; + assert!(matches!(decode(&nine, 8), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); + // every prefix fails cleanly + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + for n in 0..enc.len() { + let _ = decode(&enc[..n], 8); + } +} + +/// Test-only writer of the selection-value-8 coding (the inverse of the rule in [`decode_hasselblad`]), with +/// a 5-bit code for every category. Category-16 differences carry `FFFF` as their ignored bits. +pub(crate) fn encode_hasselblad(data: &[u16], width: usize, height: usize) -> Vec { + assert!(width.is_multiple_of(2) && data.len() == width * height); + let mut bits: Vec = vec![]; + let put = |bits: &mut Vec, v: u32, n: u32| { + for i in (0..n).rev() { + bits.push((v >> i) & 1 == 1); + } + }; + for row in data.chunks_exact(width) { + let mut last = [32768i32, 32768]; + for pair in row.as_chunks::<2>().0 { + let mut d = [0i32; 2]; + for k in 0..2 { + let mut v = (pair[k] as i32 - last[k]) & 0xffff; + if v >= 32768 { + v -= 65536; + } + d[k] = v; + last[k] = pair[k] as i32; + } + let s = [ssss_of(d[0]), ssss_of(d[1])]; + put(&mut bits, s[0] as u32, 5); + put(&mut bits, s[1] as u32, 5); + for k in 0..2 { + match s[k] { + 0 => {} + 16 => put(&mut bits, 0xffff, 16), + n => { + let v = if d[k] < 0 { d[k] - 1 } else { d[k] }; + put(&mut bits, v as u32 & ((1 << n) - 1), n as u32); + } + } + } + } + } + let mut entropy = vec![]; + for word in bits.chunks(32) { + let w = word.iter().enumerate().fold(0u32, |a, (i, &b)| a | ((b as u32) << (31 - i))); + entropy.extend_from_slice(&w.to_le_bytes()); + } + let mut counts = [0u8; 16]; + counts[4] = 17; + let values: Vec = (0..=16).collect(); + hasselblad_stream(width as u16, height as u16, &counts, &values, &entropy) +} + +#[test] +fn selection_value_8_round_trips_through_the_test_writer() { + // extremes force SSSS 16 and wrap-around; noise exercises every other category + let mut data = noise(10 * 7, 16, 9); + data[0] = 0; + data[1] = 65535; + data[2] = 65535; + data[3] = 0; + let enc = encode_hasselblad(&data, 10, 7); + assert_eq!(decode(&enc, 70).unwrap().data, data); +} + +#[test] +fn code_lengths_are_limited_to_16() { + // Fibonacci-like frequencies produce very long codes before adjustment + let mut f = [0u32; 17]; + let (mut a, mut b) = (1u32, 1u32); + for x in f.iter_mut() { + *x = a; + let c = a.saturating_add(b); + a = b; + b = c; + } + let (counts, values) = code_lengths(&f); + assert_eq!(counts.iter().map(|&c| c as usize).sum::(), 17); + assert_eq!(values.len(), 17); + assert!(Huffman::new(&counts, &values).is_ok()); +} diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 6cf224984..df85e2703 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -656,18 +656,38 @@ fn fix_pixels(buf: &mut [f32], w: usize, h: usize, cpp: usize, cfa: Option<&Cfa> } } +/// Most full-image passes of one `OpcodeList3`: every warp record is a full-image allocation and resample +/// pass and every bad-pixel record a full flatten-and-rebuild, and a hostile record may repeat either +/// thousands of times. Real DNGs carry at most a couple; passes past the cap are skipped. +const MAX_LIST3_PASSES: usize = 8; + /// Apply `OpcodeList3` (on the demosaiced RGB image, values normalised to [0, 1]). pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) { if list.is_empty() { return; } let (w, h) = (img.width, img.height); + let mut passes = 0usize; for op in applied_ops(list) { match op { - Opcode::WarpRectilinear { planes, center } => *img = warp_rectilinear(img, planes, *center), - Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => *img = warp_rectilinear2(img, planes, *center, *reciprocal), + Opcode::WarpRectilinear { planes, center } => { + if passes < MAX_LIST3_PASSES { + passes += 1; + *img = warp_rectilinear(img, planes, *center); + } + } + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => { + if passes < MAX_LIST3_PASSES { + passes += 1; + *img = warp_rectilinear2(img, planes, *center, *reciprocal); + } + } Opcode::WarpFisheye { .. } | Opcode::TrimBounds { .. } | Opcode::Unknown { .. } => {} other => { + if passes >= MAX_LIST3_PASSES { + continue; + } + passes += 1; let mut flat: Vec = img.data.iter().flat_map(|p| *p).collect(); apply_list(std::slice::from_ref(other), &mut flat, w, h, 3, None, 1.0); for (d, c) in img.data.iter_mut().zip(flat.as_chunks::<3>().0) { @@ -734,428 +754,4 @@ pub fn warp_rectilinear2(img: &Rgb32f, planes: &[[f64; WR2_LEN]], center: [f64; } #[cfg(test)] -pub(crate) mod tests { - use super::*; - - pub(crate) struct ListWriter(pub Vec, pub u32); - - impl ListWriter { - pub fn new() -> Self { - ListWriter(Vec::new(), 0) - } - pub fn op(&mut self, id: u32, params: &[u8]) -> &mut Self { - for v in [id, 0x01030000, 0, params.len() as u32] { - self.0.extend_from_slice(&v.to_be_bytes()); - } - self.0.extend_from_slice(params); - self.1 += 1; - self - } - pub fn finish(&self) -> Vec { - let mut v = self.1.to_be_bytes().to_vec(); - v.extend_from_slice(&self.0); - v - } - } - - pub(crate) fn be(vals: &[f64]) -> Vec { - vals.iter().flat_map(|v| v.to_be_bytes()).collect() - } - pub(crate) fn area(a: [u32; 8]) -> Vec { - a.iter().flat_map(|v| v.to_be_bytes()).collect() - } - - #[test] - fn parse_and_apply_area_ops() { - let (w, h) = (4usize, 3usize); - let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); - for v in [2u32, 2] { - gm.extend_from_slice(&v.to_be_bytes()); - } - gm.extend(be(&[1.0, 1.0, 0.0, 0.0])); - gm.extend_from_slice(&1u32.to_be_bytes()); - for g in [1.0f32, 2.0, 1.0, 2.0] { - gm.extend_from_slice(&g.to_be_bytes()); - } - let mut dpr = area([0, 0, 3, 4, 0, 1, 1, 1]); - dpr.extend_from_slice(&3u32.to_be_bytes()); - for d in [0.0f32, 0.1, 0.2] { - dpr.extend_from_slice(&d.to_be_bytes()); - } - let mut poly = area([0, 0, 3, 4, 0, 1, 1, 2]); - poly.extend_from_slice(&1u32.to_be_bytes()); - poly.extend(be(&[0.0, 2.0])); - let blob = ListWriter::new().op(9, &gm).op(10, &dpr).op(8, &poly).op(99, &[1, 2, 3]).finish(); - let list = parse_list(&blob); - assert_eq!(list.len(), 4); - assert!(matches!(list[3], Opcode::Unknown { id: 99, .. })); - assert!(list[..3].iter().all(|o| o.is_applied())); - let mut buf = vec![0.25f32; w * h]; - apply_list(&list, &mut buf, w, h, 1, None, 1.0); - // gain map: horizontal ramp 1 → 2 across relative x 0..1 (x / w) - let expect = |x: usize, y: usize| { - let g = 1.0 + x as f32 / w as f32; - let v = 0.25 * g + [0.0, 0.1, 0.2][y]; - if x.is_multiple_of(2) { v * 2.0 } else { v } - }; - for y in 0..h { - for x in 0..w { - assert!((buf[y * w + x] - expect(x, y)).abs() < 1e-6, "{x},{y}: {} vs {}", buf[y * w + x], expect(x, y)); - } - } - } - - #[test] - fn map_table_raw_scale_and_bad_pixels() { - let mut t = area([0, 0, 2, 2, 0, 1, 1, 1]); - t.extend_from_slice(&3u32.to_be_bytes()); - for v in [0u16, 30000, 65535] { - t.extend_from_slice(&v.to_be_bytes()); - } - let list = parse_list(&ListWriter::new().op(7, &t).finish()); - let mut buf = vec![0.0, 1.0 / 65535.0, 2.0 / 65535.0, 0.5]; - apply_list(&list, &mut buf, 2, 2, 1, None, 1.0); - assert_eq!(buf[0], 0.0); - assert!((buf[1] - 30000.0 / 65535.0).abs() < 1e-7); - assert_eq!(buf[3], 1.0); - - let mut p = vec![]; - for v in [7u32, 0] { - p.extend_from_slice(&v.to_be_bytes()); - } - let list = parse_list(&ListWriter::new().op(4, &p).finish()); - let cfa = Cfa::bayer("RGGB").unwrap(); - let mut img: Vec = (0..36).map(|i| 100.0 + (i % 6) as f32).collect(); - img[2 * 6 + 2] = 7.0; - apply_list(&list, &mut img, 6, 6, 1, Some(&cfa), 65535.0); - assert_eq!(img[2 * 6 + 2], 102.0); - } - - /// A mosaic equal to the bad-pixel constant, or one rectangle over the whole image, used to list every pixel as - /// bad: w × h entries (plus a set of them), tens of GB at the decoder's size limit. Here the rows between a good - /// first row (500) and a good last row (900) are bad: 2052 rows of 2048 pixels, four rows more than the 1 << 22 - /// cap. Row 1, within the cap, is repaired from the first row; the second-last row, past the cap, must be left - /// as it is (uncapped, it was rewritten from the last row). - fn assert_bad_pixels_capped(op: Opcode) { - let (w, h) = (2048, 2054); - let mut buf = vec![0.0f32; w * h]; - buf[..w].fill(500.0); - buf[(h - 1) * w..].fill(900.0); - apply_list(&[op], &mut buf, w, h, 1, None, 65535.0); - assert!(buf[w..2 * w].iter().all(|&v| v == 500.0), "row 1 (within the cap) not repaired: {:?}", &buf[w..w + 4]); - let past = &buf[(h - 2) * w..(h - 1) * w]; - assert!(past.iter().all(|&v| v == 0.0), "a row past the cap was rewritten: {:?}", &past[..4]); - } - - #[test] - fn bad_pixels_constant_is_capped() { - assert_bad_pixels_capped(Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 0 }); - } - - #[test] - fn bad_pixels_list_is_capped_inside_a_rectangle() { - // top, left, bottom, right: every row but the first and the last - assert_bad_pixels_capped(Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![], rects: vec![[1, 0, 2053, 2048]] }); - } - - #[test] - fn vignette_and_warp_identity() { - let mut img = Rgb32f::from_fn(9, 7, |x, y| [x as f32 / 9.0, y as f32 / 7.0, 0.5]); - let orig = img.clone(); - let mut p = be(&[0.0; 5]); - p.extend(be(&[0.5, 0.5])); - let mut warp = 1u32.to_be_bytes().to_vec(); - warp.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); - let list = parse_list(&ListWriter::new().op(3, &p).op(1, &warp).finish()); - assert_eq!(list.len(), 2); - apply_list3(&list, &mut img); - for (a, b) in img.data.iter().zip(&orig.data) { - for c in 0..3 { - assert!((a[c] - b[c]).abs() < 1e-5); - } - } - // positive k0 brightens the corners more than the centre - let mut p = be(&[1.0, 0.0, 0.0, 0.0, 0.0]); - p.extend(be(&[0.5, 0.5])); - let list = parse_list(&ListWriter::new().op(3, &p).finish()); - let mut flat = Rgb32f::filled(9, 9, [0.5; 3]); - apply_list3(&list, &mut flat); - assert!(flat.get(0, 0)[0] > 0.85 && (flat.get(4, 4)[0] - 0.5).abs() < 0.01, "{:?}", flat.get(0, 0)); - } - - #[test] - fn warp_barrel_moves_pixels_outward() { - let img = Rgb32f::from_fn(31, 31, |x, y| if x == 24 && y == 15 { [1.0; 3] } else { [0.0; 3] }); - // source radius = r (1 + k r²) with k < 0 → content moves outward - let out = warp_rectilinear(&img, &[[1.0, -0.6, 0.0, 0.0, 0.0, 0.0]], [0.5, 0.5]); - let (bx, _) = (0..31).map(|x| (x, out.get(x, 15)[0])).fold((0, 0.0), |a, b| if b.1 > a.1 { b } else { a }); - assert!(bx >= 25, "{bx}"); - } - - #[test] - fn write_parse_roundtrip() { - let a = Area { top: 1, left: 2, bottom: 30, right: 40, plane: 0, planes: 3, row_pitch: 1, col_pitch: 2 }; - let list = vec![ - Opcode::WarpRectilinear { planes: vec![[1.0, 0.01, -0.002, 0.0, 1e-4, -2e-4]; 3], center: [0.5, 0.49] }, - Opcode::WarpFisheye { planes: vec![[1.0, 0.1, 0.0, 0.0]], center: [0.5, 0.5] }, - Opcode::FixVignetteRadial { k: [0.1, 0.2, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, - Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 1 }, - Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![(3, 4)], rects: vec![[1, 1, 2, 2]] }, - Opcode::TrimBounds { top: 1, left: 1, bottom: 9, right: 9 }, - Opcode::MapTable { area: a, table: vec![0, 100, 65535] }, - Opcode::MapPolynomial { area: a, coefficients: vec![0.0, 1.0, -0.1] }, - Opcode::GainMap { area: a, points_v: 2, points_h: 1, spacing: [1.0, 1.0], origin: [0.0, 0.0], map_planes: 1, gains: vec![1.0, 1.5] }, - Opcode::DeltaPerRow { area: a, deltas: vec![0.1, 0.2] }, - Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] }, - Opcode::ScalePerRow { area: a, scales: vec![1.1] }, - Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] }, - Opcode::WarpRectilinear2 { - planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])], - center: [0.5, 0.49], - reciprocal: true, - optional: true, - }, - Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] }, - ]; - assert_eq!(parse_list(&write_list(&list)), list); - } - - #[test] - fn truncated_lists_do_not_panic() { - let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); - gm.extend_from_slice(&[0xff; 64]); - let blob = ListWriter::new().op(9, &gm).op(5, &[0xff; 12]).op(1, &[0, 0, 0, 9]).finish(); - for n in 0..blob.len() { - let l = parse_list(&blob[..n]); - let mut buf = vec![0.5f32; 12]; - apply_list(&l, &mut buf, 4, 3, 1, None, 1.0); - } - } - - /// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius, - /// then the centre and `reciprocalRadial`). - fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec { - let mut p = (sets.len() as u32).to_be_bytes().to_vec(); - for s in sets { - p.extend(be(s)); - } - p.extend(be(¢er)); - p.extend_from_slice(&reciprocal.to_be_bytes()); - p - } - - fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] { - let mut k = [0.0; WR2_LEN]; - for &(i, v) in kr { - k[i] = v; - } - k[18] = 1.0; - k - } - - #[test] - fn warp_rectilinear2_parses_the_spec_layout() { - let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]); - k[15] = 0.001; - k[16] = -0.002; - k[17] = 0.1; - k[18] = 0.9; - let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish(); - let list = parse_list(&blob); - assert_eq!( - list, - vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }] - ); - assert!(list[0].is_applied() && list[0].is_lens_correction()); - // written back as opcode 14, version 1.6.0.0, optional flag kept - let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }]; - let bytes = write_list(&again); - assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..])); - assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..])); - assert_eq!(parse_list(&bytes), again); - } - - #[test] - fn warp_rectilinear2_identity_leaves_the_image() { - let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]); - for reciprocal in [false, true] { - let mut i = img.clone(); - let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32); - apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i); - for (a, b) in i.data.iter().zip(&img.data) { - for c in 0..3 { - assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}"); - } - } - } - } - - #[test] - fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() { - // kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4 - let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]); - let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]); - let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]); - k[15] = 0.01; - k[16] = -0.02; - let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false); - for (p, q) in a.data.iter().zip(&b.data) { - for c in 0..3 { - assert!((p[c] - q[c]).abs() < 1e-5); - } - } - // odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines - let k = wr2_set(&[(0, 1.0), (1, 0.5)]); - assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25)); - assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8)); - let mut k = k; - k[18] = 0.5; // max_valid_radius - assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25)); - k[17] = 0.25; // min_valid_radius - assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125)); - assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0"); - } - - #[test] - fn malformed_warp_rectilinear2_is_unknown_not_a_panic() { - let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); - let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]); - assert!(is_wr2(&good)); - // every truncation and any trailing byte - for n in 0..good.len() { - assert!(!is_wr2(&good[..n]), "{n}"); - } - assert!(!is_wr2(&[&good[..], &[0u8][..]].concat())); - // N = 0, N too big for the data, N beyond the plane limit - for n in [0u32, 2, 5, u32::MAX] { - let mut p = good.clone(); - p[..4].copy_from_slice(&n.to_be_bytes()); - assert!(!is_wr2(&p), "N = {n}"); - } - // non-finite or absurd values, in a coefficient, the valid range and the centre - let patch = |at: usize, v: f64| { - let mut p = good.clone(); - p[at..at + 8].copy_from_slice(&v.to_be_bytes()); - p - }; - for (at, v) in [ - (4, f64::NAN), - (4 + 8, f64::INFINITY), - (4 + 8 * 3, 1e300), - (4 + 8 * 17, -0.5), - (4 + 8 * 18, 1.5), - (4 + 8 * 17, 1.0), - (4 + 19 * 8, f64::NAN), - (4 + 19 * 8 + 8, 1e30), - ] { - assert!(!is_wr2(&patch(at, v)), "{at} {v}"); - } - // refused ones stay Unknown and are not applied - let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish()); - assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }])); - let mut img = Rgb32f::filled(5, 5, [0.5; 3]); - apply_list3(&list, &mut img); - assert_eq!(img.get(2, 2), [0.5; 3]); - // a hostile but accepted set (huge radial terms) still gives a finite image - let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]); - let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]); - for reciprocal in [false, true] { - let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal); - assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); - } - } - - #[test] - fn warp_list_order_and_the_optional_skip_rule() { - let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional }; - let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; - let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; - let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] }; - let d = std::mem::discriminant::; - let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::>(); - // WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped - assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]); - assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]); - // not optional: both run, in list order - assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]); - // only the opcode immediately after is skipped - assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]); - // two in a row: both run, and the WarpRectilinear after the optional second one is skipped - assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]); - // a WarpRectilinear before the WarpRectilinear2 runs, in list order - assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]); - // the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1 - let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); - let mut one = 1u32.to_be_bytes().to_vec(); - one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); - let mut blob = 2u32.to_be_bytes().to_vec(); - for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] { - for v in [id, 0x0106_0000, flags, p.len() as u32] { - blob.extend_from_slice(&v.to_be_bytes()); - } - blob.extend_from_slice(p); - } - let list = parse_list(&blob); - assert_eq!(list.len(), 2); - assert_eq!(applied_ops(&list).count(), 1); - // a refused (Unknown) opcode 14 triggers no skip - let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] }; - assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. }))); - } - - /// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A - /// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ. - #[test] - fn cropped_lens_corrections_correct_the_crop_like_the_whole() { - let (w, h) = (90usize, 64usize); - let crop = crate::Rect::new(7, 5, 70, 50); - let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]); - let mut wr2 = [0.0; WR2_LEN]; - wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]); - wr2[15] = 0.002; - wr2[16] = -0.001; - wr2[17] = 0.05; - wr2[18] = 0.9; - let warps = [ - Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] }, - Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true }, - Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false }, - ]; - for op in &warps { - let mut whole = ramp.clone(); - apply_list3(std::slice::from_ref(op), &mut whole); - let re = op.cropped(w, h, crop).unwrap(); - let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height); - apply_list3(std::slice::from_ref(&re), &mut part); - let mut worst = 0f32; - for y in 6..crop.height - 6 { - for x in 6..crop.width - 6 { - let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]); - worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max); - } - } - assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}"); - } - // the vignette, in the warps' centre convention the develop pipeline reads it with - let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| { - let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0); - let (px, py) = (c[0] * aw, c[1] * ah); - let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2); - 1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::() - }; - let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]); - let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else { - panic!("vignette") - }; - for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] { - let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y)); - assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}"); - } - // no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge - assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]); - assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none()); - assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none()); - assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none()); - assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none()); - } -} +pub(crate) mod tests; diff --git a/crates/raw/src/opcodes/tests.rs b/crates/raw/src/opcodes/tests.rs new file mode 100644 index 000000000..e63a0bf56 --- /dev/null +++ b/crates/raw/src/opcodes/tests.rs @@ -0,0 +1,444 @@ +use super::*; + +pub(crate) struct ListWriter(pub Vec, pub u32); + +impl ListWriter { + pub fn new() -> Self { + ListWriter(Vec::new(), 0) + } + pub fn op(&mut self, id: u32, params: &[u8]) -> &mut Self { + for v in [id, 0x01030000, 0, params.len() as u32] { + self.0.extend_from_slice(&v.to_be_bytes()); + } + self.0.extend_from_slice(params); + self.1 += 1; + self + } + pub fn finish(&self) -> Vec { + let mut v = self.1.to_be_bytes().to_vec(); + v.extend_from_slice(&self.0); + v + } +} + +pub(crate) fn be(vals: &[f64]) -> Vec { + vals.iter().flat_map(|v| v.to_be_bytes()).collect() +} +pub(crate) fn area(a: [u32; 8]) -> Vec { + a.iter().flat_map(|v| v.to_be_bytes()).collect() +} + +/// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them +/// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must +/// still apply, exactly, so the same image comes out. +#[test] +fn list3_pass_cap_skips_repeated_records() { + let mut img = Rgb32f::new(8, 6); + for (i, px) in img.data.iter_mut().enumerate() { + *px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32]; + } + let orig: Vec<[f32; 3]> = img.data.clone(); + let list: Vec = + (0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect(); + apply_list3(&list, &mut img); + assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest"); + + // a real warp below the cap is still applied + let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }]; + apply_list3(&one, &mut img); + assert_ne!(img.data, orig, "a genuine warp record is not skipped"); +} + +#[test] +fn parse_and_apply_area_ops() { + let (w, h) = (4usize, 3usize); + let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); + for v in [2u32, 2] { + gm.extend_from_slice(&v.to_be_bytes()); + } + gm.extend(be(&[1.0, 1.0, 0.0, 0.0])); + gm.extend_from_slice(&1u32.to_be_bytes()); + for g in [1.0f32, 2.0, 1.0, 2.0] { + gm.extend_from_slice(&g.to_be_bytes()); + } + let mut dpr = area([0, 0, 3, 4, 0, 1, 1, 1]); + dpr.extend_from_slice(&3u32.to_be_bytes()); + for d in [0.0f32, 0.1, 0.2] { + dpr.extend_from_slice(&d.to_be_bytes()); + } + let mut poly = area([0, 0, 3, 4, 0, 1, 1, 2]); + poly.extend_from_slice(&1u32.to_be_bytes()); + poly.extend(be(&[0.0, 2.0])); + let blob = ListWriter::new().op(9, &gm).op(10, &dpr).op(8, &poly).op(99, &[1, 2, 3]).finish(); + let list = parse_list(&blob); + assert_eq!(list.len(), 4); + assert!(matches!(list[3], Opcode::Unknown { id: 99, .. })); + assert!(list[..3].iter().all(|o| o.is_applied())); + let mut buf = vec![0.25f32; w * h]; + apply_list(&list, &mut buf, w, h, 1, None, 1.0); + // gain map: horizontal ramp 1 → 2 across relative x 0..1 (x / w) + let expect = |x: usize, y: usize| { + let g = 1.0 + x as f32 / w as f32; + let v = 0.25 * g + [0.0, 0.1, 0.2][y]; + if x.is_multiple_of(2) { v * 2.0 } else { v } + }; + for y in 0..h { + for x in 0..w { + assert!((buf[y * w + x] - expect(x, y)).abs() < 1e-6, "{x},{y}: {} vs {}", buf[y * w + x], expect(x, y)); + } + } +} + +#[test] +fn map_table_raw_scale_and_bad_pixels() { + let mut t = area([0, 0, 2, 2, 0, 1, 1, 1]); + t.extend_from_slice(&3u32.to_be_bytes()); + for v in [0u16, 30000, 65535] { + t.extend_from_slice(&v.to_be_bytes()); + } + let list = parse_list(&ListWriter::new().op(7, &t).finish()); + let mut buf = vec![0.0, 1.0 / 65535.0, 2.0 / 65535.0, 0.5]; + apply_list(&list, &mut buf, 2, 2, 1, None, 1.0); + assert_eq!(buf[0], 0.0); + assert!((buf[1] - 30000.0 / 65535.0).abs() < 1e-7); + assert_eq!(buf[3], 1.0); + + let mut p = vec![]; + for v in [7u32, 0] { + p.extend_from_slice(&v.to_be_bytes()); + } + let list = parse_list(&ListWriter::new().op(4, &p).finish()); + let cfa = Cfa::bayer("RGGB").unwrap(); + let mut img: Vec = (0..36).map(|i| 100.0 + (i % 6) as f32).collect(); + img[2 * 6 + 2] = 7.0; + apply_list(&list, &mut img, 6, 6, 1, Some(&cfa), 65535.0); + assert_eq!(img[2 * 6 + 2], 102.0); +} + +/// A mosaic equal to the bad-pixel constant, or one rectangle over the whole image, used to list every pixel as +/// bad: w × h entries (plus a set of them), tens of GB at the decoder's size limit. Here the rows between a good +/// first row (500) and a good last row (900) are bad: 2052 rows of 2048 pixels, four rows more than the 1 << 22 +/// cap. Row 1, within the cap, is repaired from the first row; the second-last row, past the cap, must be left +/// as it is (uncapped, it was rewritten from the last row). +fn assert_bad_pixels_capped(op: Opcode) { + let (w, h) = (2048, 2054); + let mut buf = vec![0.0f32; w * h]; + buf[..w].fill(500.0); + buf[(h - 1) * w..].fill(900.0); + apply_list(&[op], &mut buf, w, h, 1, None, 65535.0); + assert!(buf[w..2 * w].iter().all(|&v| v == 500.0), "row 1 (within the cap) not repaired: {:?}", &buf[w..w + 4]); + let past = &buf[(h - 2) * w..(h - 1) * w]; + assert!(past.iter().all(|&v| v == 0.0), "a row past the cap was rewritten: {:?}", &past[..4]); +} + +#[test] +fn bad_pixels_constant_is_capped() { + assert_bad_pixels_capped(Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 0 }); +} + +#[test] +fn bad_pixels_list_is_capped_inside_a_rectangle() { + // top, left, bottom, right: every row but the first and the last + assert_bad_pixels_capped(Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![], rects: vec![[1, 0, 2053, 2048]] }); +} + +#[test] +fn vignette_and_warp_identity() { + let mut img = Rgb32f::from_fn(9, 7, |x, y| [x as f32 / 9.0, y as f32 / 7.0, 0.5]); + let orig = img.clone(); + let mut p = be(&[0.0; 5]); + p.extend(be(&[0.5, 0.5])); + let mut warp = 1u32.to_be_bytes().to_vec(); + warp.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); + let list = parse_list(&ListWriter::new().op(3, &p).op(1, &warp).finish()); + assert_eq!(list.len(), 2); + apply_list3(&list, &mut img); + for (a, b) in img.data.iter().zip(&orig.data) { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-5); + } + } + // positive k0 brightens the corners more than the centre + let mut p = be(&[1.0, 0.0, 0.0, 0.0, 0.0]); + p.extend(be(&[0.5, 0.5])); + let list = parse_list(&ListWriter::new().op(3, &p).finish()); + let mut flat = Rgb32f::filled(9, 9, [0.5; 3]); + apply_list3(&list, &mut flat); + assert!(flat.get(0, 0)[0] > 0.85 && (flat.get(4, 4)[0] - 0.5).abs() < 0.01, "{:?}", flat.get(0, 0)); +} + +#[test] +fn warp_barrel_moves_pixels_outward() { + let img = Rgb32f::from_fn(31, 31, |x, y| if x == 24 && y == 15 { [1.0; 3] } else { [0.0; 3] }); + // source radius = r (1 + k r²) with k < 0 → content moves outward + let out = warp_rectilinear(&img, &[[1.0, -0.6, 0.0, 0.0, 0.0, 0.0]], [0.5, 0.5]); + let (bx, _) = (0..31).map(|x| (x, out.get(x, 15)[0])).fold((0, 0.0), |a, b| if b.1 > a.1 { b } else { a }); + assert!(bx >= 25, "{bx}"); +} + +#[test] +fn write_parse_roundtrip() { + let a = Area { top: 1, left: 2, bottom: 30, right: 40, plane: 0, planes: 3, row_pitch: 1, col_pitch: 2 }; + let list = vec![ + Opcode::WarpRectilinear { planes: vec![[1.0, 0.01, -0.002, 0.0, 1e-4, -2e-4]; 3], center: [0.5, 0.49] }, + Opcode::WarpFisheye { planes: vec![[1.0, 0.1, 0.0, 0.0]], center: [0.5, 0.5] }, + Opcode::FixVignetteRadial { k: [0.1, 0.2, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 1 }, + Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![(3, 4)], rects: vec![[1, 1, 2, 2]] }, + Opcode::TrimBounds { top: 1, left: 1, bottom: 9, right: 9 }, + Opcode::MapTable { area: a, table: vec![0, 100, 65535] }, + Opcode::MapPolynomial { area: a, coefficients: vec![0.0, 1.0, -0.1] }, + Opcode::GainMap { area: a, points_v: 2, points_h: 1, spacing: [1.0, 1.0], origin: [0.0, 0.0], map_planes: 1, gains: vec![1.0, 1.5] }, + Opcode::DeltaPerRow { area: a, deltas: vec![0.1, 0.2] }, + Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] }, + Opcode::ScalePerRow { area: a, scales: vec![1.1] }, + Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] }, + Opcode::WarpRectilinear2 { + planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])], + center: [0.5, 0.49], + reciprocal: true, + optional: true, + }, + Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] }, + ]; + assert_eq!(parse_list(&write_list(&list)), list); +} + +#[test] +fn truncated_lists_do_not_panic() { + let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); + gm.extend_from_slice(&[0xff; 64]); + let blob = ListWriter::new().op(9, &gm).op(5, &[0xff; 12]).op(1, &[0, 0, 0, 9]).finish(); + for n in 0..blob.len() { + let l = parse_list(&blob[..n]); + let mut buf = vec![0.5f32; 12]; + apply_list(&l, &mut buf, 4, 3, 1, None, 1.0); + } +} + +/// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius, +/// then the centre and `reciprocalRadial`). +fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec { + let mut p = (sets.len() as u32).to_be_bytes().to_vec(); + for s in sets { + p.extend(be(s)); + } + p.extend(be(¢er)); + p.extend_from_slice(&reciprocal.to_be_bytes()); + p +} + +fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] { + let mut k = [0.0; WR2_LEN]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + k +} + +#[test] +fn warp_rectilinear2_parses_the_spec_layout() { + let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]); + k[15] = 0.001; + k[16] = -0.002; + k[17] = 0.1; + k[18] = 0.9; + let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish(); + let list = parse_list(&blob); + assert_eq!( + list, + vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }] + ); + assert!(list[0].is_applied() && list[0].is_lens_correction()); + // written back as opcode 14, version 1.6.0.0, optional flag kept + let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }]; + let bytes = write_list(&again); + assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..])); + assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..])); + assert_eq!(parse_list(&bytes), again); +} + +#[test] +fn warp_rectilinear2_identity_leaves_the_image() { + let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]); + for reciprocal in [false, true] { + let mut i = img.clone(); + let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32); + apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i); + for (a, b) in i.data.iter().zip(&img.data) { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}"); + } + } + } +} + +#[test] +fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() { + // kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4 + let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]); + let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]); + let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]); + k[15] = 0.01; + k[16] = -0.02; + let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false); + for (p, q) in a.data.iter().zip(&b.data) { + for c in 0..3 { + assert!((p[c] - q[c]).abs() < 1e-5); + } + } + // odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines + let k = wr2_set(&[(0, 1.0), (1, 0.5)]); + assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25)); + assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8)); + let mut k = k; + k[18] = 0.5; // max_valid_radius + assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25)); + k[17] = 0.25; // min_valid_radius + assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125)); + assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0"); +} + +#[test] +fn malformed_warp_rectilinear2_is_unknown_not_a_panic() { + let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]); + assert!(is_wr2(&good)); + // every truncation and any trailing byte + for n in 0..good.len() { + assert!(!is_wr2(&good[..n]), "{n}"); + } + assert!(!is_wr2(&[&good[..], &[0u8][..]].concat())); + // N = 0, N too big for the data, N beyond the plane limit + for n in [0u32, 2, 5, u32::MAX] { + let mut p = good.clone(); + p[..4].copy_from_slice(&n.to_be_bytes()); + assert!(!is_wr2(&p), "N = {n}"); + } + // non-finite or absurd values, in a coefficient, the valid range and the centre + let patch = |at: usize, v: f64| { + let mut p = good.clone(); + p[at..at + 8].copy_from_slice(&v.to_be_bytes()); + p + }; + for (at, v) in [ + (4, f64::NAN), + (4 + 8, f64::INFINITY), + (4 + 8 * 3, 1e300), + (4 + 8 * 17, -0.5), + (4 + 8 * 18, 1.5), + (4 + 8 * 17, 1.0), + (4 + 19 * 8, f64::NAN), + (4 + 19 * 8 + 8, 1e30), + ] { + assert!(!is_wr2(&patch(at, v)), "{at} {v}"); + } + // refused ones stay Unknown and are not applied + let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish()); + assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }])); + let mut img = Rgb32f::filled(5, 5, [0.5; 3]); + apply_list3(&list, &mut img); + assert_eq!(img.get(2, 2), [0.5; 3]); + // a hostile but accepted set (huge radial terms) still gives a finite image + let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]); + let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]); + for reciprocal in [false, true] { + let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal); + assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); + } +} + +#[test] +fn warp_list_order_and_the_optional_skip_rule() { + let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional }; + let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] }; + let d = std::mem::discriminant::; + let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::>(); + // WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped + assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]); + assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]); + // not optional: both run, in list order + assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]); + // only the opcode immediately after is skipped + assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]); + // two in a row: both run, and the WarpRectilinear after the optional second one is skipped + assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]); + // a WarpRectilinear before the WarpRectilinear2 runs, in list order + assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]); + // the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1 + let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let mut one = 1u32.to_be_bytes().to_vec(); + one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); + let mut blob = 2u32.to_be_bytes().to_vec(); + for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] { + for v in [id, 0x0106_0000, flags, p.len() as u32] { + blob.extend_from_slice(&v.to_be_bytes()); + } + blob.extend_from_slice(p); + } + let list = parse_list(&blob); + assert_eq!(list.len(), 2); + assert_eq!(applied_ops(&list).count(), 1); + // a refused (Unknown) opcode 14 triggers no skip + let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] }; + assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. }))); +} + +/// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A +/// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ. +#[test] +fn cropped_lens_corrections_correct_the_crop_like_the_whole() { + let (w, h) = (90usize, 64usize); + let crop = crate::Rect::new(7, 5, 70, 50); + let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]); + let mut wr2 = [0.0; WR2_LEN]; + wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]); + wr2[15] = 0.002; + wr2[16] = -0.001; + wr2[17] = 0.05; + wr2[18] = 0.9; + let warps = [ + Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] }, + Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true }, + Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false }, + ]; + for op in &warps { + let mut whole = ramp.clone(); + apply_list3(std::slice::from_ref(op), &mut whole); + let re = op.cropped(w, h, crop).unwrap(); + let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height); + apply_list3(std::slice::from_ref(&re), &mut part); + let mut worst = 0f32; + for y in 6..crop.height - 6 { + for x in 6..crop.width - 6 { + let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]); + worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max); + } + } + assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}"); + } + // the vignette, in the warps' centre convention the develop pipeline reads it with + let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| { + let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0); + let (px, py) = (c[0] * aw, c[1] * ah); + let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2); + 1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::() + }; + let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]); + let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else { + panic!("vignette") + }; + for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] { + let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y)); + assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}"); + } + // no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge + assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]); + assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none()); + assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none()); + assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none()); + assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none()); +} diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 95f9bdcaa..2047174da 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -103,8 +103,6 @@ fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> { let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?]))); Some((be16(7)?, be16(5)?, at(9)?)) } - -/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box. fn is_jxl(b: &[u8]) -> bool { b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a]) } @@ -128,20 +126,17 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) } if matches!(ifd.u16(t::COMPRESSION), Some(6) | Some(7) | Some(34892)) && let Ok(info) = ifd.image() + && let Some(chunk) = info.single_chunk(data.len() as u64) + && let Some(s) = chunk_bytes(data, &chunk) { - let chunks = info.chunks(data.len() as u64); - if chunks.len() == 1 - && let Some(s) = chunk_bytes(data, &chunks[0]) - { - out.push(s); - } + out.push(s); } // a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL) && matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB)) && let Ok(info) = ifd.image() - && let [chunk] = info.chunks(data.len() as u64).as_slice() - && let Some(s) = chunk_bytes(data, chunk) + && let Some(chunk) = info.single_chunk(data.len() as u64) + && let Some(s) = chunk_bytes(data, &chunk) && is_jxl(s) { out.push(s); @@ -329,7 +324,13 @@ fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> { if m == 0xda { // entropy-coded data up to the next marker that is not a stuffed 0xff or a restart loop { - *i += b.get(*i..)?.iter().position(|&x| x == 0xff)?; + let Some(p) = b.get(*i..)?.iter().position(|&x| x == 0xff) else { + // the terminal search examined the rest of the slice: charge it before failing, + // or a crafted file can repeat full-file searches while the budget stays positive + *i = b.len(); + return None; + }; + *i += p; match *b.get(*i + 1)? { 0x00 | 0xd0..=0xd7 => *i += 2, 0xff => *i += 1, @@ -353,9 +354,29 @@ fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> { /// share a budget of eight times the file's size, so the scan stays linear (a real file's candidates /// fail within a few bytes, or are the preview itself). fn scan_for_jpeg(data: &[u8]) -> Option> { + scan_with_budget(data, scan_budget(data.len())).0 +} + +/// The walks' shared budget: eight times the file's size, at least 1 MiB (a tiny file still gets a +/// useful allowance), saturating so a hostile length cannot wrap it. +fn scan_budget(len: usize) -> usize { + len.saturating_mul(8).max(1 << 20) +} + +/// [`scan_for_jpeg`] with the walks' shared budget passed in, returning the walks' charged extent: +/// `(preview, bytes charged)`. The counter records the work each walk did whether or not budget +/// remains, so it stays a measure of work rather than of `budget - left`; a last walk may overshoot +/// the remaining budget by its own reach. It is a conservative charged-work measure, not literally +/// the bytes read: marker payloads skipped by `*i += len` are charged without being read, and a +/// failed terminal search is charged to the end of the slice. That over-count is safe for the +/// budget. It is a `u64` with saturating addition because the unbudgeted worst case is quadratic in +/// the file size (32-bit `usize` could otherwise overflow), and it costs one addition per +/// candidate, no allocation. +fn scan_with_budget(data: &[u8], budget: usize) -> (Option>, u64) { let mut best: Option<(usize, usize)> = None; let mut at = 1; - let mut budget = data.len().saturating_mul(8).max(1 << 20); + let mut left = budget; + let mut walked = 0u64; while let Some(off) = data.get(at..).and_then(|s| s.iter().position(|&b| b == 0xd8)) { let d8 = at + off; at = d8 + 1; @@ -365,8 +386,10 @@ fn scan_for_jpeg(data: &[u8]) -> Option> { let start = d8 - 1; // `at` starts at 1, so d8 >= 1 let mut reach = start; let found = jpeg_extent(data, start, &mut reach); - budget = budget.saturating_sub(reach.saturating_sub(start)); - if budget == 0 { + let spent = reach.saturating_sub(start); + left = left.saturating_sub(spent); + walked = walked.saturating_add(spent as u64); + if left == 0 { break; } if let Some((end, 3)) = found { @@ -376,12 +399,14 @@ fn scan_for_jpeg(data: &[u8]) -> Option> { at = end; } } - let (start, end) = best?; - let mut jpeg = data.get(start..end)?.to_vec(); - if let Some(first) = jpeg.first_mut() { - *first = 0xff; - } - Some(jpeg) + let jpeg = best.and_then(|(start, end)| { + let mut jpeg = data.get(start..end)?.to_vec(); + if let Some(first) = jpeg.first_mut() { + *first = 0xff; + } + Some(jpeg) + }); + (jpeg, walked) } /// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes. @@ -397,8 +422,14 @@ fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { /// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0 /// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller /// `THMB` box has the same shape. Returns the larger valid one. +/// Marker-walk budget of the CR3 fallback: candidates can overlap (each claiming the rest of the file) and +/// [`is_dct_jpeg`] walks each marker by marker, so walking every candidate would be quadratic in file size. +/// The `THMB`/`PRVW` previews this finds are far below the cap together; once it is spent the scan stops. +const CR3_MAX_WALK: usize = 8 << 20; + fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { let mut best: Option<&[u8]> = None; + let mut walked = 0usize; for tag in [b"PRVW", b"THMB"] { let mut from = 0; while let Some(i) = bytes.get(from..).and_then(|s| s.windows(4).position(|w| w == tag)).map(|p| p + from) { @@ -411,6 +442,11 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { continue; } let jpeg = &bytes[j..j + len]; + // One that doesn't fit the budget left is skipped; a smaller one later may still fit. + if walked + jpeg.len() > CR3_MAX_WALK { + continue; + } + walked += jpeg.len(); if is_dct_jpeg(jpeg) { if best.is_none_or(|b| b.len() < jpeg.len()) { best = Some(jpeg); @@ -429,305 +465,4 @@ fn trim_eoi(s: &[u8]) -> &[u8] { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; - - fn fake_jpeg(n: usize) -> Vec { - let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0]; - j.extend(std::iter::repeat_n(0x55u8, n)); - j.extend_from_slice(&[0xff, 0xd9]); - j - } - - /// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the - /// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than - /// the JPEG holds the way a padded container does. - fn raf_fixture(jpeg: &[u8], gap: usize, preamble: usize, declared_len: Option) -> Vec { - let head = 160 + preamble; - let jpeg_off = head; - let mut f = vec![0u8; head]; - f[..16].copy_from_slice(b"FUJIFILMCCD-RAW "); - f[84..88].copy_from_slice(&(jpeg_off as u32).to_be_bytes()); - f[88..92].copy_from_slice(&(declared_len.unwrap_or(jpeg.len()) as u32).to_be_bytes()); - // records directory and raw block are absent (offset 0), which `header` reads as "none" - f.extend_from_slice(jpeg); - f.extend(std::iter::repeat_n(0x33u8, gap)); - f - } - - /// A RAF's preview pointer sits at a fixed offset, so a header-sized prefix locates it and the - /// caller reads just that range instead of the whole file — the whole point on a network share. - #[test] - fn bounded_preview_locates_a_rafs_jpeg_from_the_header_alone() { - let jpeg = fake_jpeg(5000); - let file = raf_fixture(&jpeg, 40_000, 0, None); - // A prefix that covers the pointer's range: header + JPEG, and nothing of the tail. - let prefix_len = 8192.min(file.len()); - let prefix = &file[..prefix_len]; - - let range = bounded_preview(prefix).expect("a RAF's preview is found from its header"); - assert_eq!(range.offset, 160); - assert_eq!(range.window_len, jpeg.len() as u64); - assert!(range.offset + range.window_len <= prefix_len as u64, "the prefix covers the whole range: {range:?} of {prefix_len}"); - assert!((range.offset + range.window_len) < file.len() as u64, "the range stops short of the tail: {range:?} of {}", file.len()); - - // What the caller reads, and what it holds afterwards: identical to the whole-file answer. - let window = prefix.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the range lies inside the prefix"); - let bounded = embedded_preview_from_range(window, range).expect("the range holds the preview"); - assert_eq!(Some(bounded.clone()), embedded_preview(&file)); - assert!(bounded.ends_with(&[0xff, 0xd9]), "the preview ends at its EOI marker"); - - // A prefix that stops inside the declared extent is clamped by the container reader. The - // caller then reads a short window; what comes back is the whole JPEG it holds (the EOI - // marker is inside), never bytes past the window. - let short = &file[..2000]; - let clipped = bounded_preview(short).expect("a pointer before the cut is still found"); - assert!(clipped.window_len < jpeg.len() as u64, "clamped: {clipped:?}"); - let window = short.get(clipped.offset as usize..).expect("inside"); - let preview = embedded_preview_from_range(window, clipped).expect("the bytes are a JPEG start"); - // No end-of-image marker inside the window: the bytes come back as they are, exactly as the - // whole-file path returns an unclosed JPEG. The decoder refuses it later; nothing is invented. - assert_eq!(preview, window, "an unclosed JPEG is handed on unchanged, never padded"); - assert!(preview.len() <= window.len(), "never past the window"); - } - - /// The preview pointer may sit past the prefix (a long maker-note record before it), and a - /// container may declare padding after the JPEG: both stay correct, and the padding is dropped. - #[test] - fn bounded_preview_handles_a_late_pointer_and_padding() { - let jpeg = fake_jpeg(300); - let late = raf_fixture(&jpeg, 0, 9000, None); - let range = bounded_preview(&late).expect("the whole file is always a valid prefix"); - assert_eq!(range.offset, (160 + 9000) as u64); - let padded = raf_fixture(&jpeg, 500, 0, Some(jpeg.len() + 500)); - let range = bounded_preview(&padded).expect("a padded extent is still located"); - assert_eq!(range.window_len, (jpeg.len() + 500) as u64); - let window = padded.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the window is inside the file"); - let preview = embedded_preview_from_range(window, range).expect("the padding is dropped"); - assert_eq!(preview, embedded_preview(&padded).expect("the whole-file answer agrees")); - assert!(preview.ends_with(&[0xff, 0xd9])); - } - - /// Without a usable header or pointer the caller must read the file whole: the answer is `None`, - /// never a wrong range. - #[test] - fn bounded_preview_declines_what_the_header_cannot_answer() { - let jpeg = fake_jpeg(64); - let file = raf_fixture(&jpeg, 0, 0, None); - assert_eq!(bounded_preview(&jpeg), None, "not a raw at all"); - assert_eq!(bounded_preview(b""), None); - if let Some(short) = file.get(..64) { - assert_eq!(bounded_preview(short), None, "a header cut short has not shown the pointer yet"); - } - // a pointer that claims more than the file holds: `header` clamps the slice to the buffer, - // so the range never runs past the end (no out-of-bounds read can follow from it) - let mut lying = raf_fixture(&jpeg, 4096, 0, None); - lying[88..92].copy_from_slice(&(u32::MAX / 2).to_be_bytes()); - let clamped = bounded_preview(&lying).expect("a pointer inside the file is still usable"); - assert!(clamped.offset + clamped.window_len <= lying.len() as u64, "clamped to the file: {clamped:?}"); - // a range the window cannot satisfy yields nothing - assert_eq!(embedded_preview_from_range(&jpeg, PreviewRange { offset: 0, window_len: u64::MAX }), None); - // a TIFF-based raw: its pointer tag lives in an IFD this prefix may cut short - let mut tiff = b"II*\0\x08\0\0\0".to_vec(); - tiff.extend_from_slice(&[0u8; 64]); - assert_eq!(bounded_preview(&tiff), None); - } - - /// A range that does not hold a JPEG yields nothing, rather than a truncated image handed on. - #[test] - fn a_range_that_does_not_hold_a_jpeg_yields_nothing() { - let jpeg = fake_jpeg(200); - let range = PreviewRange { offset: 0, window_len: jpeg.len() as u64 }; - assert_eq!(embedded_preview_from_range(&jpeg, range).as_deref(), Some(&jpeg[..])); - assert_eq!(embedded_preview_from_range(&jpeg[..10], range), None, "window shorter than the range claims"); - let not_jpeg = vec![0u8; jpeg.len()]; - assert_eq!(embedded_preview_from_range(¬_jpeg, range), None, "not a JPEG"); - assert_eq!(embedded_preview_from_range(&[], PreviewRange { offset: 0, window_len: 0 }), None, "empty window"); - } - - #[test] - fn picks_largest_dct_jpeg() { - let small = fake_jpeg(10); - let big = fake_jpeg(500); - let lossless = crate::ljpeg::encode(&[1u16; 64], 8, 8, 1, 12, 1, 0); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::COMPRESSION, Value::Short(vec![6])); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![1])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![1])); - ifd0.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![small.clone()] }); - let mut raw = IfdBuilder::new(); - raw.set(t::COMPRESSION, Value::Short(vec![7])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![8])); - raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![lossless] }); - ifd0.add_sub_ifd(raw); - let mut sub = IfdBuilder::new(); - sub.set(t::COMPRESSION, Value::Short(vec![7])); - sub.set(t::IMAGE_WIDTH, Value::Long(vec![2])); - sub.set(t::IMAGE_LENGTH, Value::Long(vec![2])); - sub.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - sub.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![big.clone()] }); - ifd0.add_sub_ifd(sub); - let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); - assert_eq!(embedded_preview(&bytes).unwrap(), big); - assert!(embedded_preview(b"nope").is_none()); - // CR3: a PRVW box after the ftyp - let mut cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); - let j = fake_jpeg(300); - cr3.extend_from_slice(&((24 + j.len()) as u32).to_be_bytes()); - cr3.extend_from_slice(b"PRVW\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01"); - cr3.extend_from_slice(&(j.len() as u32).to_be_bytes()); - cr3.extend_from_slice(&j); - assert_eq!(embedded_preview(&cr3).unwrap(), j); - for n in 0..cr3.len() { - let _ = embedded_preview(&cr3[..n]); - } - } - - /// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components. - fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec { - let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8]; - j.extend_from_slice(&h.to_be_bytes()); - j.extend_from_slice(&w.to_be_bytes()); - j.push(nc); - for c in 0..nc { - j.extend_from_slice(&[c + 1, 0x11, 0]); - } - j.extend(std::iter::repeat_n(0x55u8, n)); - j.extend_from_slice(&[0xff, 0xd9]); - j - } - - /// A TIFF raw holding `jpegs` as JPEG-compressed strips: the first in IFD0, the others in sub-IFDs. - fn tiff_with_jpegs(jpegs: &[&[u8]]) -> Vec { - let ifd = |j: &[u8]| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::COMPRESSION, Value::Short(vec![6])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![1])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![j.to_vec()] }); - ifd - }; - let mut ifd0 = ifd(jpegs[0]); - for j in &jpegs[1..] { - ifd0.add_sub_ifd(ifd(j)); - } - TiffWriter::default().write(&[ifd0]).unwrap() - } - - /// A stand-in decodes the smallest JPEG that covers its box and shows the same picture as the largest: not a - /// thumbnail of another shape (a letterboxed one) or a grey mask, and the largest when no smaller one is big enough. - #[test] - fn a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape() { - let (big, small, boxed) = (sized_jpeg(9984, 6656, 3, 500), sized_jpeg(1616, 1080, 3, 50), sized_jpeg(160, 120, 3, 10)); - // a ProRAW DNG keeps grey semantic masks of the picture's shape as JPEGs too - let mask = sized_jpeg(768, 512, 1, 20); - let file = tiff_with_jpegs(&[&small, &big, &boxed, &mask]); - assert_eq!(embedded_preview(&file).unwrap(), big, "unchanged: the largest"); - assert_eq!(embedded_preview_covering(&file, 1600).unwrap(), small, "1616 px covers a 1600 px box"); - assert_eq!(embedded_preview_covering(&file, 1616).unwrap(), small); - assert_eq!(embedded_preview_covering(&file, 1617).unwrap(), big, "too small: the largest"); - assert_eq!(embedded_preview_covering(&file, u32::MAX).unwrap(), big); - assert_eq!(embedded_preview_covering(&file, 100).unwrap(), small, "the 4:3 thumbnail and the grey mask are not this picture"); - // a small copy of the same shape (within 1 %) is taken - let thumb = sized_jpeg(160, 107, 3, 10); - let file = tiff_with_jpegs(&[&small, &big, &thumb]); - assert_eq!(embedded_preview_covering(&file, 100).unwrap(), thumb); - assert_eq!(embedded_preview_covering(&tiff_with_jpegs(&[&big]), 100).unwrap(), big, "one preview: that one"); - for n in (0..file.len()).step_by(7) { - let _ = embedded_preview_covering(&file[..n], 100); - } - } - - /// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box. - #[test] - fn cr3_prefers_the_full_size_jpeg_track() { - let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; - let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat()); - let (small, big) = (fake_jpeg(300), fake_jpeg(3000)); - let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); - let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec(); - prvw.extend_from_slice(&(small.len() as u32).to_be_bytes()); - prvw.extend_from_slice(&small); - file.extend(bx(b"PRVW", &prvw)); - // CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at` - let mut craw = vec![0u8; 82]; - craw.extend(bx(b"JPEG", &[0; 4])); - let at = 2048u64; - let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat()); - let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat()); - let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat()); - let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat())))); - file.extend(bx(b"moov", &trak)); - file.resize(at as usize, 0); - file.extend_from_slice(&big); - assert_eq!(embedded_preview(&file).unwrap(), big); - // a track pointing past the end falls back to PRVW - file.truncate(at as usize + 100); - assert_eq!(embedded_preview(&file).unwrap(), small); - } - - /// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes. - fn scan_jpeg(nc: u8, n: usize) -> Vec { - let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00]; - j.extend(std::iter::repeat_n(8u8, 64)); - j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]); - for c in 0..nc { - j.extend_from_slice(&[c + 1, 0x11, 0]); - } - j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]); - for c in 0..nc { - j.extend_from_slice(&[c + 1, 0]); - } - j.extend_from_slice(&[0, 63, 0]); - j.extend(std::iter::repeat_n(0x55u8, n)); - j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]); - j - } - - /// A crafted container full of JPEG-like starts (each walk running on through the ones after it) - /// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute). - #[test] - fn a_scan_through_many_fake_jpegs_stays_linear() { - let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02]; - unit.extend(std::iter::repeat_n(0x55u8, 190)); - let mut f = b"\0MRM\0\x01\0\0".to_vec(); - for _ in 0..200_000 { - f.extend_from_slice(&unit); - } - let t = std::time::Instant::now(); - assert_eq!(scan_for_jpeg(&f), None); - assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); - } - - /// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning. - #[test] - fn scanned_containers_give_the_largest_colour_jpeg() { - let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000)); - let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"]; - for head in heads { - let mut f = head.to_vec(); - for part in [&small, &mosaic, &big] { - f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG - f.extend_from_slice(part); - f.extend_from_slice(&[0u8; 7]); - } - // MRW decodes; its preview is still found by the scan - assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}"); - assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}"); - for n in (0..f.len()).step_by(11) { - let _ = embedded_preview(&f[..n]); - } - } - // a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored - let mut f = b"\0MRM\0\x01\0\0".to_vec(); - f.push(0x02); - f.extend_from_slice(&big[1..]); - assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..])); - // only a one-component JPEG, or none: nothing - let mut f = b"FOVb\x02\0\x02\0".to_vec(); - f.extend_from_slice(&mosaic); - assert_eq!(embedded_preview(&f), None); - } -} +mod tests; diff --git a/crates/raw/src/preview/tests.rs b/crates/raw/src/preview/tests.rs new file mode 100644 index 000000000..2ee098414 --- /dev/null +++ b/crates/raw/src/preview/tests.rs @@ -0,0 +1,424 @@ +use super::*; +use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + +fn fake_jpeg(n: usize) -> Vec { + let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0]; + j.extend(std::iter::repeat_n(0x55u8, n)); + j.extend_from_slice(&[0xff, 0xd9]); + j +} + +/// Preview extraction only serves single-chunk JPEG-compressed IFDs and asks the `single_chunk` first: +/// a TIFF whose strip array declares 100 000 one-row strips and no StripByteCounts must not build their +/// descriptor vector, and of course finds no preview in it. +#[test] +fn a_multi_strip_jpeg_ifd_costs_no_descriptor_vector() { + let mut ifd = IfdBuilder::new(); + ifd.set(t::COMPRESSION, Value::Short(vec![7])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![100_000])); + ifd.set(t::ROWS_PER_STRIP, Value::Short(vec![1])); + ifd.set(t::STRIP_OFFSETS, Value::Byte((0..100_000).map(|i| (i % 255 + 1) as u8).collect())); + let file = TiffWriter::default().write(&[ifd]).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(embedded_preview(&file))); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None)); +} + +/// CR3 candidates claim overlapping extents: each block's first bytes double as a 40-byte jump marker so +/// a walk jogs past the next header and reaches the end of the file, making every one of the 9000 +/// candidates walk its whole suffix before the marker-walk budget — quadratic without it. Nothing here +/// is a JPEG (no frame header), so the answer is `None` either way. +#[test] +fn cr3_preview_boxes_stop_walking_when_the_budget_is_spent() { + // one 256-byte block per candidate, all marker-aligned so walks chain through later blocks: + // 0..4, size (itself a jump marker of 40 bytes); 4..8, "PRVW"; 20..24, the claimed length + // (to the file end); 24..28, an SOI plus a marker jumping to the fill at 40; 64..256, a fill of + // 4-byte markers (`ff 01 00 02`). + let blocks = 9000usize; + let total = blocks * 256; + let mut f = Vec::with_capacity(total); + for k in 0..blocks { + let base = k * 256; + f.extend_from_slice(&[0xff, 0x01, 0x00, 0x26, b'P', b'R', b'V', b'W', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + f.extend_from_slice(&u32::try_from(total - base - 24).unwrap().to_be_bytes()); + f.extend_from_slice(&[0xff, 0xd8, 0x00, 0x24, 0xff, 0x01, 0x00, 0x26, 0, 0, 0, 0, 0, 0, 0, 0]); + f.resize(base + 64, 0); // 40..64 filler: walks jump over it, so its bytes are free + for _ in 0..48 { + f.extend_from_slice(&[0xff, 0x01, 0x00, 0x02]); + } + } + assert_eq!(f.len(), total); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(cr3_preview_boxes(&f).map(|s| s.len()))); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None), "no PRVW candidate is a JPEG"); +} + +/// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the +/// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than +/// the JPEG holds the way a padded container does. +fn raf_fixture(jpeg: &[u8], gap: usize, preamble: usize, declared_len: Option) -> Vec { + let head = 160 + preamble; + let jpeg_off = head; + let mut f = vec![0u8; head]; + f[..16].copy_from_slice(b"FUJIFILMCCD-RAW "); + f[84..88].copy_from_slice(&(jpeg_off as u32).to_be_bytes()); + f[88..92].copy_from_slice(&(declared_len.unwrap_or(jpeg.len()) as u32).to_be_bytes()); + // records directory and raw block are absent (offset 0), which `header` reads as "none" + f.extend_from_slice(jpeg); + f.extend(std::iter::repeat_n(0x33u8, gap)); + f +} + +/// A RAF's preview pointer sits at a fixed offset, so a header-sized prefix locates it and the +/// caller reads just that range instead of the whole file — the whole point on a network share. +#[test] +fn bounded_preview_locates_a_rafs_jpeg_from_the_header_alone() { + let jpeg = fake_jpeg(5000); + let file = raf_fixture(&jpeg, 40_000, 0, None); + // A prefix that covers the pointer's range: header + JPEG, and nothing of the tail. + let prefix_len = 8192.min(file.len()); + let prefix = &file[..prefix_len]; + + let range = bounded_preview(prefix).expect("a RAF's preview is found from its header"); + assert_eq!(range.offset, 160); + assert_eq!(range.window_len, jpeg.len() as u64); + assert!(range.offset + range.window_len <= prefix_len as u64, "the prefix covers the whole range: {range:?} of {prefix_len}"); + assert!((range.offset + range.window_len) < file.len() as u64, "the range stops short of the tail: {range:?} of {}", file.len()); + + // What the caller reads, and what it holds afterwards: identical to the whole-file answer. + let window = prefix.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the range lies inside the prefix"); + let bounded = embedded_preview_from_range(window, range).expect("the range holds the preview"); + assert_eq!(Some(bounded.clone()), embedded_preview(&file)); + assert!(bounded.ends_with(&[0xff, 0xd9]), "the preview ends at its EOI marker"); + + // A prefix that stops inside the declared extent is clamped by the container reader. The + // caller then reads a short window; what comes back is the whole JPEG it holds (the EOI + // marker is inside), never bytes past the window. + let short = &file[..2000]; + let clipped = bounded_preview(short).expect("a pointer before the cut is still found"); + assert!(clipped.window_len < jpeg.len() as u64, "clamped: {clipped:?}"); + let window = short.get(clipped.offset as usize..).expect("inside"); + let preview = embedded_preview_from_range(window, clipped).expect("the bytes are a JPEG start"); + // No end-of-image marker inside the window: the bytes come back as they are, exactly as the + // whole-file path returns an unclosed JPEG. The decoder refuses it later; nothing is invented. + assert_eq!(preview, window, "an unclosed JPEG is handed on unchanged, never padded"); + assert!(preview.len() <= window.len(), "never past the window"); +} + +/// The preview pointer may sit past the prefix (a long maker-note record before it), and a +/// container may declare padding after the JPEG: both stay correct, and the padding is dropped. +#[test] +fn bounded_preview_handles_a_late_pointer_and_padding() { + let jpeg = fake_jpeg(300); + let late = raf_fixture(&jpeg, 0, 9000, None); + let range = bounded_preview(&late).expect("the whole file is always a valid prefix"); + assert_eq!(range.offset, (160 + 9000) as u64); + let padded = raf_fixture(&jpeg, 500, 0, Some(jpeg.len() + 500)); + let range = bounded_preview(&padded).expect("a padded extent is still located"); + assert_eq!(range.window_len, (jpeg.len() + 500) as u64); + let window = padded.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the window is inside the file"); + let preview = embedded_preview_from_range(window, range).expect("the padding is dropped"); + assert_eq!(preview, embedded_preview(&padded).expect("the whole-file answer agrees")); + assert!(preview.ends_with(&[0xff, 0xd9])); +} + +/// Without a usable header or pointer the caller must read the file whole: the answer is `None`, +/// never a wrong range. +#[test] +fn bounded_preview_declines_what_the_header_cannot_answer() { + let jpeg = fake_jpeg(64); + let file = raf_fixture(&jpeg, 0, 0, None); + assert_eq!(bounded_preview(&jpeg), None, "not a raw at all"); + assert_eq!(bounded_preview(b""), None); + if let Some(short) = file.get(..64) { + assert_eq!(bounded_preview(short), None, "a header cut short has not shown the pointer yet"); + } + // a pointer that claims more than the file holds: `header` clamps the slice to the buffer, + // so the range never runs past the end (no out-of-bounds read can follow from it) + let mut lying = raf_fixture(&jpeg, 4096, 0, None); + lying[88..92].copy_from_slice(&(u32::MAX / 2).to_be_bytes()); + let clamped = bounded_preview(&lying).expect("a pointer inside the file is still usable"); + assert!(clamped.offset + clamped.window_len <= lying.len() as u64, "clamped to the file: {clamped:?}"); + // a range the window cannot satisfy yields nothing + assert_eq!(embedded_preview_from_range(&jpeg, PreviewRange { offset: 0, window_len: u64::MAX }), None); + // a TIFF-based raw: its pointer tag lives in an IFD this prefix may cut short + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&[0u8; 64]); + assert_eq!(bounded_preview(&tiff), None); +} + +/// A range that does not hold a JPEG yields nothing, rather than a truncated image handed on. +#[test] +fn a_range_that_does_not_hold_a_jpeg_yields_nothing() { + let jpeg = fake_jpeg(200); + let range = PreviewRange { offset: 0, window_len: jpeg.len() as u64 }; + assert_eq!(embedded_preview_from_range(&jpeg, range).as_deref(), Some(&jpeg[..])); + assert_eq!(embedded_preview_from_range(&jpeg[..10], range), None, "window shorter than the range claims"); + let not_jpeg = vec![0u8; jpeg.len()]; + assert_eq!(embedded_preview_from_range(¬_jpeg, range), None, "not a JPEG"); + assert_eq!(embedded_preview_from_range(&[], PreviewRange { offset: 0, window_len: 0 }), None, "empty window"); +} + +#[test] +fn picks_largest_dct_jpeg() { + let small = fake_jpeg(10); + let big = fake_jpeg(500); + let lossless = crate::ljpeg::encode(&[1u16; 64], 8, 8, 1, 12, 1, 0); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::COMPRESSION, Value::Short(vec![6])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![1])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![small.clone()] }); + let mut raw = IfdBuilder::new(); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![8])); + raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![lossless] }); + ifd0.add_sub_ifd(raw); + let mut sub = IfdBuilder::new(); + sub.set(t::COMPRESSION, Value::Short(vec![7])); + sub.set(t::IMAGE_WIDTH, Value::Long(vec![2])); + sub.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + sub.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + sub.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![big.clone()] }); + ifd0.add_sub_ifd(sub); + let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); + assert_eq!(embedded_preview(&bytes).unwrap(), big); + assert!(embedded_preview(b"nope").is_none()); + // CR3: a PRVW box after the ftyp + let mut cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); + let j = fake_jpeg(300); + cr3.extend_from_slice(&((24 + j.len()) as u32).to_be_bytes()); + cr3.extend_from_slice(b"PRVW\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01"); + cr3.extend_from_slice(&(j.len() as u32).to_be_bytes()); + cr3.extend_from_slice(&j); + assert_eq!(embedded_preview(&cr3).unwrap(), j); + for n in 0..cr3.len() { + let _ = embedded_preview(&cr3[..n]); + } +} + +/// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components. +fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec { + let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8]; + j.extend_from_slice(&h.to_be_bytes()); + j.extend_from_slice(&w.to_be_bytes()); + j.push(nc); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0x11, 0]); + } + j.extend(std::iter::repeat_n(0x55u8, n)); + j.extend_from_slice(&[0xff, 0xd9]); + j +} + +/// A TIFF raw holding `jpegs` as JPEG-compressed strips: the first in IFD0, the others in sub-IFDs. +fn tiff_with_jpegs(jpegs: &[&[u8]]) -> Vec { + let ifd = |j: &[u8]| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::COMPRESSION, Value::Short(vec![6])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![1])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![j.to_vec()] }); + ifd + }; + let mut ifd0 = ifd(jpegs[0]); + for j in &jpegs[1..] { + ifd0.add_sub_ifd(ifd(j)); + } + TiffWriter::default().write(&[ifd0]).unwrap() +} + +/// A stand-in decodes the smallest JPEG that covers its box and shows the same picture as the largest: not a +/// thumbnail of another shape (a letterboxed one) or a grey mask, and the largest when no smaller one is big enough. +#[test] +fn a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape() { + let (big, small, boxed) = (sized_jpeg(9984, 6656, 3, 500), sized_jpeg(1616, 1080, 3, 50), sized_jpeg(160, 120, 3, 10)); + // a ProRAW DNG keeps grey semantic masks of the picture's shape as JPEGs too + let mask = sized_jpeg(768, 512, 1, 20); + let file = tiff_with_jpegs(&[&small, &big, &boxed, &mask]); + assert_eq!(embedded_preview(&file).unwrap(), big, "unchanged: the largest"); + assert_eq!(embedded_preview_covering(&file, 1600).unwrap(), small, "1616 px covers a 1600 px box"); + assert_eq!(embedded_preview_covering(&file, 1616).unwrap(), small); + assert_eq!(embedded_preview_covering(&file, 1617).unwrap(), big, "too small: the largest"); + assert_eq!(embedded_preview_covering(&file, u32::MAX).unwrap(), big); + assert_eq!(embedded_preview_covering(&file, 100).unwrap(), small, "the 4:3 thumbnail and the grey mask are not this picture"); + // a small copy of the same shape (within 1 %) is taken + let thumb = sized_jpeg(160, 107, 3, 10); + let file = tiff_with_jpegs(&[&small, &big, &thumb]); + assert_eq!(embedded_preview_covering(&file, 100).unwrap(), thumb); + assert_eq!(embedded_preview_covering(&tiff_with_jpegs(&[&big]), 100).unwrap(), big, "one preview: that one"); + for n in (0..file.len()).step_by(7) { + let _ = embedded_preview_covering(&file[..n], 100); + } +} + +/// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box. +#[test] +fn cr3_prefers_the_full_size_jpeg_track() { + let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; + let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat()); + let (small, big) = (fake_jpeg(300), fake_jpeg(3000)); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec(); + prvw.extend_from_slice(&(small.len() as u32).to_be_bytes()); + prvw.extend_from_slice(&small); + file.extend(bx(b"PRVW", &prvw)); + // CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at` + let mut craw = vec![0u8; 82]; + craw.extend(bx(b"JPEG", &[0; 4])); + let at = 2048u64; + let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat()); + let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat()); + let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat()); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat())))); + file.extend(bx(b"moov", &trak)); + file.resize(at as usize, 0); + file.extend_from_slice(&big); + assert_eq!(embedded_preview(&file).unwrap(), big); + // a track pointing past the end falls back to PRVW + file.truncate(at as usize + 100); + assert_eq!(embedded_preview(&file).unwrap(), small); +} + +/// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes. +fn scan_jpeg(nc: u8, n: usize) -> Vec { + let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00]; + j.extend(std::iter::repeat_n(8u8, 64)); + j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0x11, 0]); + } + j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0]); + } + j.extend_from_slice(&[0, 63, 0]); + j.extend(std::iter::repeat_n(0x55u8, n)); + j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]); + j +} + +/// `units` copies of a 200-byte record whose `d8 ff db` start runs each walk on through the +/// starts after it (a crafted Minolta-like container). +fn many_fake_jpegs(units: usize) -> Vec { + let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02]; + unit.extend(std::iter::repeat_n(0x55u8, 190)); + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.reserve(units * unit.len()); + for _ in 0..units { + f.extend_from_slice(&unit); + } + f +} + +/// The production scan's budget policy, restated here so the tests assert an expected bound +/// independently instead of calling the helper under test: eight times the file's size, at +/// least 1 MiB. +fn policy_budget(len: usize) -> u64 { + (len as u64 * 8).max(1 << 20) +} + +/// A crafted 40 MB container full of JPEG-like starts (each walk running on through the starts +/// after it) is scanned in bounded work: the walks share a budget (without it this file would +/// walk about 8·10^10 bytes), so the charged work stays within the policy budget plus one walk's +/// reach. No wall clock: this cannot fail on a slow runner, and removing the budget shows up as +/// the work exploding. +#[test] +fn a_scan_through_many_fake_jpegs_stays_linear() { + let f = many_fake_jpegs(200_000); + assert_eq!(f.len(), 40_000_008, "the adversarial fixture the budget was measured on"); + let (found, walked) = scan_with_budget(&f, policy_budget(f.len()) as usize); + assert_eq!(found, None); + let bound = policy_budget(f.len()) + f.len() as u64; + assert!(walked <= bound, "walked {walked} bytes of a {} byte file (bound {bound})", f.len()); +} + +/// A walk whose final entropy search finds no `ff` has examined the rest of the file, so that +/// search is charged to the budget before the walk fails. Without the charge the 40 MB tail is +/// scanned for free and only the few KB before it are reported, while `left` stays positive and +/// the budget no longer bounds the work. +#[test] +fn a_failed_terminal_search_is_charged_to_the_budget() { + let mut f = many_fake_jpegs(20); + f.extend(std::iter::repeat_n(0x55u8, 40_000_000)); + let (found, walked) = scan_with_budget(&f, 1); + assert_eq!(found, None); + assert!(walked >= 40_000_000, "terminal search was not charged: {walked} of {} bytes", f.len()); +} + +/// The production wrapper stops at the same shared budget. Eighteen fake candidates each declare +/// a segment length that makes their walk jump 0xFFF0 bytes, exhausting the tiny file's 1 MiB +/// allowance before the valid JPEG after them: the wrapper finds nothing. A `usize::MAX` budget +/// (or no budget at all) would walk on and return that JPEG. +#[test] +fn the_production_wrapper_stops_at_the_shared_budget() { + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + for _ in 0..18 { + f.extend_from_slice(&[0x00, 0xd8, 0xff, 0xe1, 0xff, 0xf0]); + } + f.extend_from_slice(&scan_jpeg(3, 100)); + assert_eq!(scan_for_jpeg(&f), None); +} + +/// The shared budget is what bounds the work, and the counter reports the work itself. The same +/// generator at 200 starts is a 40 KB file: without the budget (never decremented, or restarted +/// for each candidate) the scan walks all 200 walks, 4 MB, instead of about 1 MB — so a negative +/// run costs megabytes, not the 8·10^10 bytes of the 40 MB fixture. +#[test] +fn the_scan_budget_bounds_the_work_a_crafted_file_can_cause() { + let f = many_fake_jpegs(200); + let budget = policy_budget(f.len()); + let (found, walked) = scan_with_budget(&f, budget as usize); + assert_eq!(found, None); + assert!(walked >= (f.len() as u64) / 2, "the walks ran: {walked} bytes of {}", f.len()); + assert!(walked <= budget + (f.len() as u64), "walked {walked} with a {budget} byte budget"); + // The counter records the work, not the unspent budget: a budget of one file size is spent + // by the first walk, and the second walk is still counted in full (it overshoots). + let (_, spent) = scan_with_budget(&f, f.len()); + let len = f.len() as u64; + assert!(spent > len && spent <= 2 * len, "spent {spent} with a {len}-byte budget"); + // No candidate, no work counted. + let none = b"no jpeg here"; + assert_eq!(scan_with_budget(none, policy_budget(none.len()) as usize).1, 0); + // One real candidate: the counter counts exactly the reach of its walk. + let mut one = vec![0u8]; + let jpeg = scan_jpeg(3, 100); + one.extend_from_slice(&jpeg); + let (found, walked) = scan_with_budget(&one, policy_budget(one.len()) as usize); + assert!(found.is_some()); + assert_eq!(walked, jpeg.len() as u64, "one walk of the JPEG's {} bytes", jpeg.len()); + // The production wrapper takes the same path and returns the counter's preview. + assert_eq!(scan_for_jpeg(&one).as_deref(), found.as_deref()); +} + +/// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning. +#[test] +fn scanned_containers_give_the_largest_colour_jpeg() { + let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000)); + let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"]; + for head in heads { + let mut f = head.to_vec(); + for part in [&small, &mosaic, &big] { + f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG + f.extend_from_slice(part); + f.extend_from_slice(&[0u8; 7]); + } + // MRW decodes; its preview is still found by the scan + assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}"); + assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}"); + for n in (0..f.len()).step_by(11) { + let _ = embedded_preview(&f[..n]); + } + } + // a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.push(0x02); + f.extend_from_slice(&big[1..]); + assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..])); + // only a one-component JPEG, or none: nothing + let mut f = b"FOVb\x02\0\x02\0".to_vec(); + f.extend_from_slice(&mosaic); + assert_eq!(embedded_preview(&f), None); +} diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index d67a92b40..8a404a25e 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -35,6 +35,12 @@ const MAX_ENTRIES: u64 = 8192; const MAX_BOXES: usize = 4096; /// Child IFDs listed by one `SubIFDs` entry. const MAX_CHILDREN: u64 = 4096; +/// Pending child-IFD offsets the walker may queue at once: no more than it can ever visit +/// (the same [`MAX_IFDS`] the seen set is held to, deduplicated as they are popped), so an alley +/// of hostile `SubIFDs` entries can't grow the queue into the tens of millions of pointers. +/// The plan never guesses: the walk refuses and the plan reports the whole file when it would go +/// past the cap. +const MAX_PENDING: usize = MAX_IFDS; /// The RAF raw container's pointer to its raw IFD. const RAF_RAW_IFD: u16 = 0xf000; @@ -308,6 +314,13 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result = Vec::new(); while let Some(off) = stack.pop() { if off == 0 || off >= len || seen.len() >= MAX_IFDS || !seen.insert(off) { continue; @@ -329,7 +342,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result Result inline { - meta.insert(at(start), at(value_end)); + spans.push((at(start), at(value_end))); } let child = matches!(tag, t::SUB_IFDS | t::EXIF_IFD | t::GPS_IFD | t::INTEROP_IFD) || extra.contains(&tag); let locates = child || matches!(tag, t::STRIP_OFFSETS | t::STRIP_BYTE_COUNTS | t::TILE_OFFSETS | t::TILE_BYTE_COUNTS); @@ -359,14 +372,24 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result= len || !queued.insert(v) { + continue; + } + if stack.len() >= MAX_PENDING { + return Err("too many child IFDs queued to visit"); + } stack.push(v); } } } - if complete && let Some(next) = offset_at(table_end) { + if complete && let Some(next) = offset_at(table_end).filter(|&next| next != 0 && next < len && queued.insert(next)) { + if stack.len() >= MAX_PENDING { + return Err("too many child IFDs queued to visit"); + } stack.push(next); } } + meta.extend(spans); Ok(meta) } @@ -585,8 +608,74 @@ mod tests { assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles")); } - /// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is - /// built in one sort and merge, not one pass over all strips (or all holes) per strip. + /// An IFD whose `SubIFDs` entries would queue 33 million child pointers (8192 entries of + /// 4096 values apiece, all pointing at the same array) is refused at the walker's pending + /// cap instead of growing the queue — and the plan reports the whole file, never guessing. + #[test] + fn a_subifds_alley_is_refused_at_the_pending_cap() { + // First read everything, so the walk reaches the directory table itself. + let n = 8192u32; + let len = 8 + 2 + (n * 12 + 4 + 4 * 4096) as usize; + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&(n as u16).to_le_bytes()); + let mut entry = |tag: u16, typ: u16, count: u32, value: u32| { + tiff.extend_from_slice(&tag.to_le_bytes()); + tiff.extend_from_slice(&typ.to_le_bytes()); + tiff.extend_from_slice(&count.to_le_bytes()); + tiff.extend_from_slice(&value.to_le_bytes()); + }; + // header (8) + count field (2) + the {n} numbers and pointer (4), then the value array + let values_at = (8 + 2u64 + u64::from(n) * 12 + 4) as u32; + for _ in 0..n { + entry(t::SUB_IFDS, 4, MAX_CHILDREN as u32, values_at); + } + tiff.extend_from_slice(&0u32.to_le_bytes()); + tiff.extend((1..=4096u32).flat_map(u32::to_le_bytes)); + let mut loaded = Ranges::new(); + loaded.insert(0, len as u64); + let mut w = Want::new(&tiff, &loaded); + assert_eq!(walk_tiff(&mut w, 0, &tiff, &[]), Err("too many child IFDs queued to visit")); + } + + /// A TIFF with many out-of-line values across 512 directories is planned in one sort and + /// merge, not one pass over all accumulated ranges per value (CodeRabbit #52: before, each + /// `Ranges::insert` copied every range found before it — the ~million disjoint values of the + /// fixture below ask for more copying than the timeout allows). + #[test] + fn many_out_of_line_values_plan_fast() { + let (ifds, values) = (512usize, 2048usize); + let ifd_len = 2 + values * 12 + 4; + let values_at = 8 + ifds * ifd_len; + let len = values_at + values * 8; + let mut tiff = vec![0u8; len]; + tiff[..8].copy_from_slice(b"II*\0\x08\0\0\0"); + for i in 0..ifds { + let at = 8 + i * ifd_len; + // {values} out-of-line strings (count 8 > the 4 inline bytes), then the next-IFD + // pointer; every value is a disjoint 8-byte range for the merge to handle. + tiff[at..at + 2].copy_from_slice(&(values as u16).to_le_bytes()); + for k in 0..values { + let e = at + 2 + k * 12; + tiff[e..e + 2].copy_from_slice(&t::MAKE.to_le_bytes()); + tiff[e + 2..e + 4].copy_from_slice(&2u16.to_le_bytes()); // ASCII: 8 bytes > inline — a span per entry + tiff[e + 4..e + 8].copy_from_slice(&8u32.to_le_bytes()); + tiff[e + 8..e + 12].copy_from_slice(&(values_at as u32 + (k * 8) as u32).to_le_bytes()); + } + let next = if i == ifds - 1 { 0u32 } else { (at + ifd_len) as u32 }; + tiff[at + ifd_len - 4..at + ifd_len].copy_from_slice(&next.to_le_bytes()); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut loaded = Ranges::new(); + loaded.insert(0, len as u64); + let _ = tx.send(header_plan(&tiff, &loaded)); + }); + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s"); + } + + /// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: + /// the plan is built in one sort and merge, not one pass over all strips (or all holes) per + /// strip. #[test] fn many_strips_without_byte_counts_plan_fast() { let n = 1_000_000u32; diff --git a/crates/raw/src/tests.rs b/crates/raw/src/tests.rs new file mode 100644 index 000000000..865099cc9 --- /dev/null +++ b/crates/raw/src/tests.rs @@ -0,0 +1,612 @@ +use super::*; + +#[test] +fn cfa_helpers() { + let c = Cfa::bayer("RGGB").unwrap(); + assert!(c.is_bayer()); + assert_eq!(c.color_at(0, 0), 0); + assert_eq!(c.color_at(3, 3), 2); + assert_eq!(c.shifted(1, 0).name(), "GRBG"); + assert_eq!(c.shifted(1, 1).name(), "BGGR"); + assert!(Cfa::bayer("RGGX").is_none()); + for name in ["RGGB", "BGGR", "GRBG", "GBRG"] { + assert_eq!(Cfa::bayer_static(name).name(), name); + } + let x = Cfa::xtrans(); + assert!(!x.is_bayer()); + let greens = x.pattern.iter().filter(|&&p| p == 1).count(); + assert_eq!(greens, 20); + // every 3×3 block of the X-Trans layout has 5 greens + for by in 0..2 { + for bx in 0..2 { + let g = (0..9).filter(|i| x.color_at(bx * 3 + i % 3, by * 3 + i / 3) == 1).count(); + assert_eq!(g, 5); + } + } +} + +#[test] +fn a_picture_made_elsewhere_is_finished_like_develop_does() { + let (w, h) = (12usize, 10usize); + let raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(2, 2, 8, 6), + crop: Rect::new(1, 1, 5, 4), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + lens_metadata: Default::default(), + }; + let direct = raw.develop(Method::Bilinear).unwrap(); + let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); + assert_eq!((direct.width, direct.height), (5, 4)); + assert_eq!(direct, by_hand); + // the crop is what develop keeps; no usable crop keeps everything + assert_eq!(raw.develop_crop(8, 6), Rect::new(1, 1, 5, 4)); + let mut whole = raw.clone(); + whole.crop = Rect::default(); + assert_eq!(whole.develop_crop(8, 6), Rect::new(0, 0, 8, 6)); + assert_eq!(whole.develop(Method::Bilinear).unwrap().width, 8); + // a crop that pokes out is clipped to the picture + whole.crop = Rect::new(6, 4, 10, 10); + assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); + + // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so + let mut tall = raw.clone(); + tall.pixel_aspect = 2.0; + let d = tall.develop(Method::Bilinear).unwrap(); + assert_eq!((d.width, d.height), (5, 8)); + assert_eq!(tall.info().developed_size(), (5, 8)); + assert_eq!(raw.info().developed_size(), (5, 4)); + assert!(!tall.can_bin(2)); + assert_eq!(stretched_height(1324, 2.0), 2648); + assert_eq!(stretched_height(1324, 1.0), 1324); +} + +/// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to +/// pass the bounds check (`x + width` wraps to 0) and then index out of bounds in +/// `normalized`. It must be a `Corrupt` error at validate time instead. +#[test] +fn an_active_area_that_wraps_past_the_image_is_corrupt() { + let (w, h) = (12usize, 10usize); + let mut raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16(vec![100; w * h]), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(usize::MAX, 0, 1, 1), + crop: Rect::default(), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, + }; + assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image"); + assert!(raw.normalized().is_err()); + raw.active_area = Rect::new(0, 0, w + 1, h); + assert!(raw.validate().is_err(), "one past the last column is outside too"); +} + +#[test] +fn black_levels() { + let b = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![1.0, 2.0, 3.0, 4.0], delta_h: vec![0.5, 0.0], delta_v: vec![0.0, 10.0] }; + assert_eq!(b.at(0, 0, 0, 1), 1.5); + assert_eq!(b.at(1, 1, 0, 1), 14.0); + assert_eq!(b.at(3, 2, 0, 1), 2.0); + assert_eq!(b.mean(), 2.5); + assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0); +} + +// --- TIFF shells around a private raw block (thumbnail-only IFD0) --- + +use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + +/// An uncompressed 8-bit RGB image IFD. +fn rgb_ifd(w: u32, h: u32) -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] }); + ifd +} + +fn exif_size(w: u32, h: u32) -> IfdBuilder { + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h])) +} + +fn write(chain: &[IfdBuilder]) -> Vec { + TiffWriter::default().write(chain).unwrap() +} + +const SHELL_REASON: &str = "raw image in a private block"; + +/// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw). +#[test] +fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(64, 48)); + let bytes = write(&[ifd0]); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // a sub-IFD that is not much bigger (a pyramid level) does not count + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(32, 24)); + assert_eq!(probe(&write(&[ifd0])), None); +} + +/// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it. +#[test] +fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.set_child(t::EXIF_IFD, exif_size(400, 300)); + let mut bytes = write(&[ifd0.clone()]); + // no private block: a downsized copy that kept its old Exif size is an ordinary image + assert_eq!(probe(&bytes), None); + // 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is + bytes.extend(std::iter::repeat_n(7u8, 14_000)); + assert_eq!(probe(&bytes), None); + bytes.extend(std::iter::repeat_n(7u8, 2_000)); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // truncating it never panics + for n in (0..bytes.len()).step_by(37) { + let _ = probe(&bytes[..n]); + let _ = decode(&bytes[..n]); + } +} + +/// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger. +#[test] +fn ordinary_tiffs_are_not_shells() { + let padded = |chain: &[IfdBuilder]| { + let mut b = write(chain); + b.extend(std::iter::repeat_n(0u8, 100_000)); + b + }; + let mut full = rgb_ifd(400, 300); + full.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[full])), None); + // the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it + let mut near = rgb_ifd(120, 90); + near.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[near])), None); + // a multi-page TIFF whose first page is small + assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None); + // no dimensions at all + assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None); + // raw evidence without a CFA photometric: Pentax's Exif `CFAPattern` (uncompressed / PackBits PEFs) and + // Samsung's private compressions (here 32773, PackBits elsewhere: NX1, NX500) + let mut pef = rgb_ifd(400, 300); + pef.set(t::MAKE, Value::Ascii("PENTAX Corporation".into())); + let mut exif = exif_size(400, 300); + exif.set(vendor::EXIF_CFA_PATTERN, Value::Undefined(vec![0, 2, 0, 2, 0, 1, 1, 2])); + pef.set_child(t::EXIF_IFD, exif); + assert_eq!(probe(&padded(&[pef])), Some(RawFormat::Pef)); + let mut srw = rgb_ifd(400, 300); + srw.set(t::MAKE, Value::Ascii("SAMSUNG".into())); + srw.set(t::COMPRESSION, Value::Short(vec![32773])); + assert_eq!(probe(&padded(&[srw])), Some(RawFormat::Srw)); + // camera-authored or exported TIFFs preserving camera Make tags remain ordinary images + for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { + let mut cam = rgb_ifd(400, 300); + cam.set(t::MAKE, Value::Ascii(make.into())); + cam.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[cam])), None, "{make} TIFF should not be probed as raw"); + } +} + +/// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to +/// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed +/// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model, +/// stays an ordinary image. +#[test] +fn sony_raws_without_a_cfa_ifd_are_still_raws() { + let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option| { + let mut ifd = rgb_ifd(400, 300); + ifd.set(t::MAKE, Value::Ascii("SONY".into())); + ifd.set(t::MODEL, Value::Ascii(model.into())); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + if let Some(child) = sub_ifd { + ifd.set_child(t::SUB_IFDS, child); + } + probe(&write(&[ifd])) + }; + let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer + assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF"); + assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too"); + assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs"); + assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF"); + assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF"); + // TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702) + assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail"); + assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail"); + assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model"); +} + +// --- containers that are recognised but not decoded, with a preview --- + +#[test] +fn private_containers_are_recognised_by_their_magic() { + assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); + assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); + assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); + for f in [RawFormat::Crw, RawFormat::X3f] { + assert!(!f.is_supported()); + } + assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f"))); + // too short for the CIFF signature + assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); +} + +/// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw +/// container with a preview; a TIFF reader would call it malformed. +#[test] +fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() { + let jpeg: Vec = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat(); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&jpeg); + assert_eq!(probe(&f), Some(RawFormat::OtherTiff)); + assert!(matches!(decode(&f), Err(RawError::Unsupported(_)))); + assert_eq!(embedded_preview(&f), Some(jpeg)); + // no pointer and no sub-IFDs: just a broken TIFF + let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]); + assert_eq!(probe(&g), None); +} + +/// A GoPro GPR (a DNG with VC-5 coded raw data, Compression 9) is refused by the full decode with +/// a reason that names the format, whatever its tile bytes hold; the header probe still +/// describes it (so it imports). +#[test] +fn gopro_vc5_dng_is_unsupported_with_a_clear_reason() { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + ifd.set(t::COMPRESSION, Value::Short(vec![9])); + ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); + let bytes = write(&[ifd]); + assert_eq!(probe(&bytes), Some(RawFormat::Dng)); + let want = "DNG compression 9 (GoPro VC-5) is not decoded yet"; + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported from decode") }; + assert_eq!(why, want); + let info = probe_info(&bytes).expect("header probe describes the file"); + assert_eq!((info.width, info.height), (32, 16)); +} + +/// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default +/// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it. +fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec { + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + let samples: Vec = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect(); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] }); + ifd + }; + let mut main = linear(0, w, h, 1000); + main.set(t::BLACK_LEVEL, Value::Short(vec![100])); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + for (tag, v) in main_tags { + main.set(*tag, v.clone()); + } + let mut ifd0 = rgb_ifd(4, 2); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.add_sub_ifd(main); + for &(cw, ch) in copies { + ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16)); + } + write(&[ifd0]) +} + +/// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the +/// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`. +#[test] +fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() { + let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]); + let full = decode(&file).unwrap(); + assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28))); + // a 12 px picture: the 16 px copy (its crop is 14 px long) + let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px"); + assert_eq!((small.width, small.height), (16, 8)); + assert_eq!(small.crop, Rect::new(1, 1, 14, 7)); + let RawData::U16(v) = &small.data else { panic!("16-bit samples") }; + assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples"); + assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black"); + assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's"); + // 20 px: the 32 px copy (28 px); 40 px: none covers it + assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32)); + assert!(decode_reduced(&file, 40).unwrap().is_none()); + // a different aspect ratio is not a copy of the image + assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none()); + // opcodes are in full-size pixels + let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]); + assert!(decode_reduced(&opcodes, 8).unwrap().is_none()); + // not a DNG + assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none()); +} + +/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. +#[test] +fn private_compression_99_is_a_raw() { + let mut ifd = rgb_ifd(16, 12); + ifd.set(t::COMPRESSION, Value::Short(vec![99])); + assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); +} + +/// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's +/// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD +/// without the block, or an RGB image carrying it, stays an ordinary image. +#[test] +fn uncompressed_leaf_mos_is_a_raw() { + let mosaic = |leaf_data: bool| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] }); + if leaf_data { + ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + } + ifd + }; + let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap(); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_)))); + assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_)))); + assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF"); + let mut rgb = rgb_ifd(16, 12); + rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block"); +} + +/// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767 +/// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there. +#[test] +fn hasselblad_make_with_sony_raw_coding_is_arw() { + let file = |make: &str, compression: u16, tone_curve: bool| { + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![64])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + if tone_curve { + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + } + raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("Lunar".into())); + ifd0.add_sub_ifd(raw); + write(&[ifd0]) + }; + assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw)); + assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw)); + let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap(); + assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14)); + // a Hasselblad CFA image in another coding is not an ARW + assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw)); + assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); +} + +// --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- + +/// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. +fn cfa_samples() -> Vec { + (0..48u16).map(|i| 1000 + 100 * (i / 8) + i % 8).collect() +} + +/// A small raw in the layout of the Hasselblad 3FR/FFF family: IFD0 is a reduced RGB image with the +/// colour tags, a SubIFD holds the full-resolution CFA image (no `CFAPattern`, DNG-style levels and +/// crop). `strip` is the CFA image's data and `compression` its coding. +fn plain_cfa_tiff(compression: u16, strip: Vec, pattern: Option<[u8; 4]>) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![60000])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Short(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Short(vec![4, 2])); + if let Some(p) = pattern { + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); + } + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![strip] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(5, 10), (-1, 10), (0, 10), (-5, 10), (12, 10), (3, 10), (-1, 10), (2, 10), (6, 10)])); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)])); + ifd0.add_sub_ifd(raw); + write(&[ifd0]) +} + +fn le_words(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// Known answer: samples, black and white level, crop and colour come from the file's own tags; the +/// layout it does not state is RGGB. +#[test] +fn uncompressed_cfa_tiff_without_a_dng_version_decodes() { + let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::CfaTiff, 8, 6, 16)); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!((r.black.mean(), r.white.clone()), (256.0, vec![60000.0])); + assert_eq!(r.crop, Rect::new(2, 2, 4, 2)); + assert!(color::has_matrix(&r.color)); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.6])); + // headers only: the same description without samples + let i = probe_info(&bytes).unwrap(); + assert_eq!((i.format, i.cfa.as_ref().map(Cfa::name), i.crop), (RawFormat::CfaTiff, Some("RGGB".to_string()), Rect::new(2, 2, 4, 2))); +} + +/// Without DNG's white-balance tag the file is left alone (a Kodak or Sinar TIFF with a CFA IFD, say). +#[test] +fn cfa_tiff_without_a_white_balance_tag_is_not_claimed() { + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.add_sub_ifd(raw); + assert_eq!(probe(&write(&[ifd0])), Some(RawFormat::OtherTiff)); +} + +/// A stated `CFAPattern` wins over the RGGB default. +#[test] +fn cfa_tiff_keeps_a_stated_pattern() { + let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), Some([2, 1, 1, 0])); + assert_eq!(decode(&bytes).unwrap().cfa.unwrap().name(), "BGGR"); +} + +/// The lossless-JPEG coding of the same layout goes through the lossless decoder (one strip for the +/// whole image, as the 3FR/FFF files have it). +#[test] +fn lossless_jpeg_cfa_tiff_decodes() { + let strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); +} + +/// Hasselblad's own lossless-JPEG coding (selection value 8, as in FFF and most compressed 3FR files) of the +/// same layout decodes: the file is read like the uncompressed one. +#[test] +fn selection_value_8_cfa_tiff_decodes() { + let strip = ljpeg::tests::encode_hasselblad(&cfa_samples(), 8, 6); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height), (RawFormat::CfaTiff, 8, 6)); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); + assert_eq!(probe_info(&bytes).unwrap().format, RawFormat::CfaTiff); +} + +/// A lossless-JPEG scan whose predictor selection value is neither 0 to 7 (T.81) nor 8 (Hasselblad) is not a +/// coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, and keeps the +/// stand-in preview an unsupported container gets. +#[test] +fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() { + let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); + let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + let ss = sos + 2 + 2 + 1 + 2; + assert_eq!(strip[ss], 1, "selection value byte"); + strip[ss] = 9; + assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + assert!(!RawFormat::OtherTiff.is_supported()); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("lossless JPEG") && why.contains("selection value 9"), "{why}"); +} + +/// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader. +#[test] +fn cfa_tiff_with_another_coding_is_not_claimed() { + let bytes = plain_cfa_tiff(8, vec![0; 96], None); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); +} + +/// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another +/// Samsung file stays with the Samsung format. +#[test] +fn samsung_make_with_a_pentax_maker_note_is_pef() { + let with_note = |note: &[u8], cfa: bool| { + let mut ifd = rgb_ifd(16, 12); + if cfa { + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + } + ifd.set(t::MAKE, Value::Ascii("SAMSUNG TECHWIN".into())); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + write(&[ifd]) + }; + assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", true)), Some(RawFormat::Srw)); + // without raw evidence (an RGB image from such a body) neither reader claims it (#281) + assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", false)), None); + assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", false)), None); +} + +#[test] +fn probe_rejects_junk() { + assert_eq!(probe(b""), None); + assert_eq!(probe(b"hello world, not a raw"), None); + assert_eq!(probe(b"\0\0\0\x18ftypcrx \0\0\0\x01"), Some(RawFormat::Cr3)); + assert_eq!(probe(b"FUJIFILMCCD-RAW 0201"), Some(RawFormat::Raf)); + assert!(decode(b"FUJIFILMCCD-RAW 0201").is_err()); + assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); +} diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index a781151b4..57878b881 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -105,48 +105,93 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack let chunks = info.chunks(data.len() as u64); let planar = info.planar == 2 && cpp > 1; let ccpp = if planar { 1 } else { cpp }; - let decoded: Vec> = - chunks.par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect(); + // The chunks are decoded and copied into the output in groups whose combined nominal size stays within + // a few times the output buffer's: everything decoded at once would retain the whole image twice, and + // a hostile tile table's chosen padding could stretch that much further (issue #1517). One group + // still decodes in parallel; the grid order groups make, the copies stay disjoint. + // The tile grid decides how much is decoded, whatever the file's bytes: the nominal sizes of all + // the chunks stay within a few times the image (edge tiles pad it) before any is decoded. + let nominal = chunks.iter().fold(0usize, |sum, c| sum.saturating_add(chunk_samples(c, ccpp))); + if nominal > total.saturating_mul(4).saturating_add(1 << 24) { + return Err(RawError::Corrupt("the tile grid is much larger than the image".into())); + } + let group_samples = (total / 4).max(1 << 20); let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] }; let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() }; - for r in decoded { - // a chunk that cannot be decoded is not a partial image: the file stays preview-only - let (c, px) = r?; - let (cw, ch) = (c.width as usize, c.height as usize); - let (x0, y0) = (c.x as usize, c.y as usize); - if x0 >= w || y0 >= h { - continue; + let mut decode_from = 0usize; + while decode_from < chunks.len() { + let mut to = decode_from; + let mut taken = 0usize; + for (k, c) in chunks[decode_from..].iter().enumerate() { + let n = chunk_samples(c, ccpp); + if taken > 0 && taken.saturating_add(n) > group_samples { + break; + } + taken = taken.saturating_add(n); + to = decode_from + k + 1; } - let copy_w = cw.min(w - x0); - let copy_h = ch.min(h - y0); - for y in 0..copy_h { - for x in 0..copy_w { - for s in 0..ccpp { - let src = (y * cw + x) * ccpp + s; - let dst_s = if planar { c.plane as usize } else { s }; - if dst_s >= cpp { - continue; - } - let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s; - match &px { - ChunkPx::U16(v) => { - if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) { - *o = s; - } + let decoded: Vec> = + chunks[decode_from..to].par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect(); + for r in decoded { + // a chunk that cannot be decoded is not a partial image: the file stays preview-only + let (c, px) = r?; + let (cw, ch) = (c.width as usize, c.height as usize); + let (x0, y0) = (c.x as usize, c.y as usize); + if x0 >= w || y0 >= h { + continue; + } + let copy_w = cw.min(w - x0); + let copy_h = ch.min(h - y0); + for y in 0..copy_h { + for x in 0..copy_w { + for s in 0..ccpp { + let src = (y * cw + x) * ccpp + s; + let dst_s = if planar { c.plane as usize } else { s }; + if dst_s >= cpp { + continue; } - ChunkPx::F32(v) => { - if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) { - *o = s; + let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s; + match &px { + ChunkPx::U16(v) => { + if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) { + *o = s; + } + } + ChunkPx::F32(v) => { + if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) { + *o = s; + } } } } } } } + decode_from = to; } Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) }) } +/// Bytes that `n` samples take in a row of an uncompressed or inflated chunk, laid out as +/// [`unpack_chunk`] reads them: whole bytes or 16-bit words, or 32-bit words for `Words32Msb`. +fn packed_row_bytes(n: u64, bits: u32, packing: Packing, float: bool) -> u64 { + if float { + return n.saturating_mul(u64::from(bits.div_ceil(8))); + } + let bit_len = n.saturating_mul(u64::from(bits)); + match (bits, packing) { + (8, _) => n, + (16, _) | (_, Packing::Word16) => n.saturating_mul(2), + (_, Packing::Msb | Packing::Lsb) => bit_len.div_ceil(8), + (_, Packing::Words32Msb) => bit_len.div_ceil(32).saturating_mul(4), + } +} + +/// A chunk's nominal decoded size in samples (per plane, as the decoder builds it). +fn chunk_samples(c: &Chunk, ccpp: usize) -> usize { + (c.width as usize).saturating_mul(c.height as usize).saturating_mul(ccpp) +} + #[allow(clippy::too_many_arguments)] fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result { let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?; @@ -179,9 +224,13 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack } comp::LOSSY_JPEG => { // lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples - let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?; - let mut v = vec![0u16; n]; - for y in 0..ch.min(jh) { + let (px, jw, jh, jc) = lossy_jpeg(src, cpp, MAX_JPEG_SAMPLES)?; + // the buffer holds the rows the decoded JPEG covers (the caller zero-fills the tile's + // remainder and reads only rows it fills): a tile-many-larger decoded JPEG does not + // get the nominal tile's full buffer (issue #1517) + let rows = ch.min(jh); + let mut v = vec![0u16; rows.max(1).saturating_mul(cw).saturating_mul(cpp)]; + for y in 0..rows { for x in 0..cw.min(jw) { for k in 0..cpp { v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16; @@ -191,13 +240,28 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack Ok(ChunkPx::U16(v)) } comp::ADOBE_DEFLATE | comp::DEFLATE => { - let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 }; // row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate - let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) }; + let row_bytes = packed_row_bytes(cw as u64 * cpp as u64, bits, packing, float); // the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too let inflate_len = crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; + // Deflate compresses at most about 1:1032, so a source this far under half of its + // nominal length cannot reconstruct the chunk: refused before the nominal buffer is + // inflated and predicted, which padding-only work would make gigabytes. + if (src.len() as u64).saturating_mul(2048) < inflate_len { + return Err(RawError::Corrupt(format!("a deflated chunk of {inflate_len} bytes is packed into only {} bytes", src.len()))); + } let raw = inflate(src, inflate_len as usize)?; + // The stream has to reach the last row inside the image (edge strips may stop there); + // it is laid out in nominal rows, so shorter is a truncated chunk, not a picture. + let last = packed_row_bytes(vw as u64 * cpp as u64, bits, packing, float); + let visible = row_bytes.saturating_mul(vh.saturating_sub(1) as u64).saturating_add(last); + if (raw.len() as u64) < visible { + return Err(RawError::Corrupt(format!( + "a deflated chunk holds {} bytes, fewer than the {visible} bytes its in-image samples span", + raw.len() + ))); + } unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor) } comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float), @@ -228,19 +292,31 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ()) } +/// The most samples a tiled JPEG chunk or a white-balance preview may decode to: far more than +/// any real lossy tile or preview carries, and small enough that a JPEG header alone cannot size +/// a multi-gigabyte allocation. +pub(crate) const MAX_JPEG_SAMPLES: usize = 1 << 26; + /// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels). -pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec, usize, usize, usize)> { +pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize, max_samples: usize) -> Result<(Vec, usize, usize, usize)> { use zune_core::bytestream::ZCursor; use zune_core::colorspace::ColorSpace; use zune_core::options::DecoderOptions; let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB }; let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs); let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts); - let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?; + // The header is read first: the JPEG's own dimensions, not the enclosing tile's, size every + // allocation `decode` makes, and they may claim billions of pixels. + d.decode_headers().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?; let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?; let (w, h) = (info.width as usize, info.height as usize); let c = if cpp == 1 { 1 } else { 3 }; - if w == 0 || h == 0 || px.len() < w * h * c { + let out = w as u64 * h as u64 * c as u64; + if w == 0 || h == 0 || out > max_samples as u64 { + return Err(RawError::Corrupt(format!("a lossy JPEG of {w} × {h} × {c} would exceed the {max_samples} output samples allowed"))); + } + let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?; + if px.len() < w * h * c { return Err(RawError::Corrupt("lossy JPEG tile: short output".into())); } Ok((px, w, h, c)) @@ -471,4 +547,97 @@ mod lossy_tests { // half of the visible rows are missing assert!(decode(8, 4).is_err()); } + + /// A lossy JPEG whose header alone claims more samples than any real tile or preview (here + /// 40000 × 40000 × 3) is refused from the header, before the decoder allocates its output + /// from the JPEG's own dimensions. + #[test] + fn an_oversized_lossy_jpeg_header_is_refused_before_decoding() { + // A real 8 × 8 baseline JPEG, whose frame header then claims other dimensions: the decoder + // parses the header, and the size check is what refuses it. + let px = vec![128u8; 8 * 8 * 3]; + let mut j = Vec::new(); + jpeg_encoder::Encoder::new(&mut j, 90).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap(); + // SOF0: marker, length, precision, then the height and the width + let sof = j.windows(2).position(|w| w == [0xff, 0xc0]).expect("a baseline frame header"); + let claim = |j: &mut Vec, size: u16| { + j[sof + 5..sof + 7].copy_from_slice(&size.to_be_bytes()); + j[sof + 7..sof + 9].copy_from_slice(&size.to_be_bytes()); + }; + claim(&mut j, 40_000); + let RawError::Corrupt(refused) = lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).unwrap_err() else { panic!("a corrupt-data error") }; + assert!(refused.contains("would exceed"), "{refused}"); + // a header inside the budget passes the size check (and then fails to decode only for lack of data) + claim(&mut j, 1024); + match lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES) { + Ok(_) => {} + Err(RawError::Corrupt(m)) => assert!(!m.contains("would exceed"), "the smaller header was refused for its size: {m}"), + Err(other) => panic!("unexpected error: {other:?}"), + } + } + + /// A lossy tile whose JPEG decodes far less than the nominal tile is fine: the rows the JPEG + /// covers hold its pixels, the rest of the output stays zero (and the tile's intermediate buffer + /// is sized by the JPEG, not by the nominal tile). + #[test] + fn a_small_lossy_jpeg_in_a_large_nominal_tile_decodes_with_zeros_below() { + let px: Vec = (0..8 * 8).flat_map(|_| [30u8, 90, 150]).collect(); + let mut j = Vec::new(); + jpeg_encoder::Encoder::new(&mut j, 100).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap(); + let info = ImageInfo { + width: 16, + height: 16, + bits_per_sample: vec![8, 8, 8], + samples_per_pixel: 3, + compression: comp::LOSSY_JPEG, + photometric: 34892, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: 16, tile_height: 16 }, + offsets: vec![0], + byte_counts: vec![j.len() as u64], + }; + let RawData::U16(v) = read_image(&j, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") }; + assert_eq!(v.len(), 16 * 16 * 3, "the output is the full nominal image"); + let px = |x: usize, y: usize| &v[(y * 16 + x) * 3..(y * 16 + x) * 3 + 3]; + assert!((px(3, 3)[0] as i32 - 30).abs() <= 3 && (px(3, 3)[2] as i32 - 150).abs() <= 3, "covered rows: {:?}", px(3, 3)); + assert!(px(3, 12).iter().all(|&s| s == 0), "rows below the JPEG stay zero: {:?}", px(3, 12)); + } + + /// A deflated tile is refused when its packed bytes are too few for deflate to have produced + /// the nominal chunk, or when the stream stops short of the rows inside the image; a fully + /// covered flat tile still decodes. + #[test] + fn a_short_or_undercovering_deflated_tile_is_refused() { + let info = |byte_counts: u64| ImageInfo { + width: 512, + height: 512, + bits_per_sample: vec![16], + samples_per_pixel: 1, + compression: comp::ADOBE_DEFLATE, + photometric: 32803, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: 512, tile_height: 512 }, + offsets: vec![0], + byte_counts: vec![byte_counts], + }; + // a complete flat tile: 512 KiB of zeros compresses to a few hundred bytes (well within + // deflate's packing bound) and inflates to every nominal byte the tile claims + let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 512 * 512 * 2], 6); + assert_eq!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Ok(RawData::U16(vec![0; 512 * 512]))); + // the same 1 KiB packed, but the stream claims a whole tile: a few hundred bytes cannot + // reconstruct 512 KiB from a decoder that compresses no further than about 1:1032 + let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1024], 6); + assert!(matches!(read_image(&zlib, &info(512 * 512 * 2), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))), "{zlib:?}"); + // 500 KiB of zeros compress to a stream within the packing bound and inflating to more + // bytes than the in-image samples span, but 24 KiB short of the whole nominal tile: + // refused as truncated rather than filled with the predictor's padding + let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 500 * 1000], 6); + assert!(matches!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + } } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor.rs similarity index 100% rename from crates/raw/src/vendor/mod.rs rename to crates/raw/src/vendor.rs diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs index 207bc8871..d5c5d83b4 100644 --- a/crates/raw/src/vendor/mef.rs +++ b/crates/raw/src/vendor/mef.rs @@ -89,6 +89,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // the white balance is measured from the samples, so the header probe reads them too let chunks = info.chunks(bytes.len() as u64); let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; + // 12-bit MSB packing takes 3 bytes per 2 samples; a strip that cannot hold the declared + // samples is a truncated file, not a picture — unpacking it would run past the real bytes + // through every output sample (a short strip asks for the whole allocation and more). + let need = n.div_ceil(2).saturating_mul(3); + if src.len() < need { + return Err(RawError::Corrupt(format!("a Mef strip of {} bytes cannot hold the declared {n} samples", src.len()))); + } let mut d = vec![0u16; n]; crate::unpack::unpack_msb(src, 12, &mut d); let white = super::white_from_data(&d, 12); @@ -138,6 +145,10 @@ mod tests { } fn sample(w: u32, h: u32, px: &[u16]) -> Vec { + sample_strip(w, h, &pack(px)) + } + + fn sample_strip(w: u32, h: u32, strip: &[u8]) -> Vec { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); @@ -148,7 +159,7 @@ mod tests { raw.set(t::ORIENTATION, Value::Short(vec![6])); raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] }); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip.to_vec()] }); let mut ifd0 = IfdBuilder::new(); ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into())); ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into())); @@ -156,6 +167,52 @@ mod tests { TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } + /// A strip that cannot hold the declared samples is a truncated file, refused as corrupt + /// instead of unpacked: `unpack_msb` would otherwise run `n` samples through the few real + /// bytes, filling the rest from nothing. + #[test] + fn a_strip_too_short_for_the_declared_samples_is_refused() { + let px: Vec = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + assert!(crate::decode(&f).is_ok()); + // A truncated file shaped like a real one: IFD tables first, strip last — only its first + // 20 packed bytes remain, while the IFD still declares the full 1.5 B/sample length. The + // probe accepts the file on the declared length; before the byte gate below, `unpack_msb` + // ran the dimension-sized allocation through those 20 bytes, filling it from nothing. + let u32be = |b: &[u8], a: usize| u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]); + let full = pack(&px); + let at = f.windows(full.len()).position(|w| w == full).expect("strip bytes in the file"); + let after_strip = at + full.len(); + let moved = after_strip - (at + 20); + let mut g = f[..8].to_vec(); + g.extend(std::iter::repeat_n(0u8, at + 20 - 8)); + g.extend_from_slice(&f[after_strip..]); + // repoint the first-IFD pointer and ifd0's MAKE / MODEL / SubIfds offsets (the tables now + // sit `moved` earlier); the raw sub-IFD's strip offset and byte count are patched below + let poke = |b: &mut [u8], a: usize| { + let v = u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]); + if v as usize >= after_strip { + b[a..a + 4].copy_from_slice(&(v - moved as u32).to_be_bytes()); + } + }; + let ifd0_at = u32be(&g, 4) as usize - moved; + g[4..8].copy_from_slice(&(ifd0_at as u32).to_be_bytes()); + let n = u16::from_be_bytes([g[ifd0_at], g[ifd0_at + 1]]) as usize; + for i in 0..n { + poke(&mut g, ifd0_at + 2 + i * 12 + 8); + } + // the raw sub-IFD: entries follow its count; tag 273 (StripOffsets) is the seventh, held + // inline — repoint it at the strip's new home, and append the truncated strip for real + let sub_at = u32be(&g, ifd0_at + 2 + 2 * 12 + 8) as usize; + let e_offsets = sub_at + 2 + 6 * 12 + 8; + let strip_at = g.len() as u32; + g[e_offsets..e_offsets + 4].copy_from_slice(&strip_at.to_be_bytes()); + g.extend_from_slice(&full[..20]); + assert_eq!(crate::probe(&g), Some(RawFormat::Mef)); + let r = crate::decode(&g); + assert!(matches!(r, Err(RawError::Corrupt(_))), "{r:?}"); + } + #[test] fn decodes_packed_12_bit() { let px: Vec = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect(); diff --git a/crates/raw/src/wb_estimate.rs b/crates/raw/src/wb_estimate.rs index 17a86a2a5..13cb9f8da 100644 --- a/crates/raw/src/wb_estimate.rs +++ b/crates/raw/src/wb_estimate.rs @@ -66,7 +66,7 @@ fn median(v: &mut [f64]) -> Option { fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { let jpeg = crate::preview::embedded_preview(bytes)?; - let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?; + let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3, crate::tiffraw::MAX_JPEG_SAMPLES).ok()?; if ch != 3 { return None; } diff --git a/crates/raw/tests/it/corpus.rs b/crates/raw/tests/it/corpus.rs index 94d4647d6..7f7074f91 100644 --- a/crates/raw/tests/it/corpus.rs +++ b/crates/raw/tests/it/corpus.rs @@ -1142,7 +1142,7 @@ fn nikon_in_file_distortion_is_limited_to_validated_bodies() { eprintln!("Nikon distortion boundary: {checked} corpus files checked"); } -/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/main.rs`) with their pinned SHA-256. +/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/raw_samples.rs`) with their pinned SHA-256. const SONY_SAMPLES: &[(&str, &str)] = &[ ("arw-sony-a7rm4a-compressed.arw", "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67"), ("arw-sony-a9m2-compressed.arw", "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97"), @@ -1203,7 +1203,7 @@ fn sony_pins_match_the_corpus_manifest() { lines.sort(); let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); - let manifest = include_str!("../../../../xtask/src/main.rs"); + let manifest = include_str!("../../../../xtask/src/raw_samples.rs"); for (name, sha) in SONY_SAMPLES { let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); diff --git a/crates/segment/src/fetch/mod.rs b/crates/segment/src/fetch.rs similarity index 100% rename from crates/segment/src/fetch/mod.rs rename to crates/segment/src/fetch.rs diff --git a/crates/tiff/src/image.rs b/crates/tiff/src/image.rs index ca3ff8b69..16e24f367 100644 --- a/crates/tiff/src/image.rs +++ b/crates/tiff/src/image.rs @@ -118,14 +118,34 @@ impl ImageInfo { } } - /// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data" - /// using `data_len`. - pub fn chunks(&self, data_len: u64) -> Vec { + /// The chunk count the image declares (offsets present, cut to the layout's plane capacity) with the + /// geometry `chunks` needs to place each one. No descriptors are built, so a pathological strip array + /// costs it a length check. + fn chunk_grid(&self) -> (usize, u32, usize) { let (across, down) = self.grid(); let per_plane = across as usize * down as usize; let planes = if self.planar == 2 { self.samples_per_pixel as usize } else { 1 }; + (self.offsets.len().min(per_plane.saturating_mul(planes)), across, per_plane) + } + + fn declared_chunk_count(&self) -> usize { + self.chunk_grid().0 + } + + /// The one chunk when the image has exactly one, else `None` — and no descriptor vector beyond it. Preview + /// extraction only serves single-chunk images, so it asks here before serving a hostile multi-strip layout. + pub fn single_chunk(&self, data_len: u64) -> Option { + if self.declared_chunk_count() != 1 { + return None; + } + self.chunks(data_len).into_iter().next() + } + + /// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data" + /// using `data_len`. + pub fn chunks(&self, data_len: u64) -> Vec { + let (n, across, per_plane) = self.chunk_grid(); let (cw, ch) = self.chunk_size(); - let n = self.offsets.len().min(per_plane.saturating_mul(planes)); let mut out = Vec::with_capacity(n); // the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk) let mut sorted: Vec = Vec::new(); @@ -192,6 +212,18 @@ mod tests { } } + /// One offset is one chunk, and `single_chunk` refuses to build descriptors when the image declares more. + #[test] + fn single_chunk_is_the_only_chunk() { + let one = strips(vec![50], vec![10]); + assert_eq!(one.single_chunk(100).map(|c| (c.index, c.offset, c.len)), one.chunks(100).first().map(|c| (c.index, c.offset, c.len))); + // the inferred length matches too, and never falls back past the data + let inferred = strips(vec![70], vec![]); + assert_eq!(inferred.single_chunk(100).map(|c| c.len), Some(30)); + assert!(strips(vec![50, 10], vec![10, 5]).single_chunk(100).is_none(), "a multi-chunk image has no single chunk"); + assert!(strips(Vec::new(), Vec::new()).single_chunk(100).is_none()); + } + #[test] fn missing_byte_counts_run_to_the_next_larger_offset() { let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]); diff --git a/crates/tiff/src/makernote.rs b/crates/tiff/src/makernote.rs index 7ed5e1997..288b366f6 100644 --- a/crates/tiff/src/makernote.rs +++ b/crates/tiff/src/makernote.rs @@ -55,7 +55,11 @@ pub fn parse_makernote(data: &[u8], offset: u64, len: u64, order: ByteOrder, mak Some(b"MM") => Some(ByteOrder::Big), _ => None, }; - let opts = ParseOptions { max_ifds: 64, max_depth: 3, ..Default::default() }; + // A fixed value budget: a maker note is at most tens of kilobytes of values in any honest file, and + // computing the budget from the buffer's length multiplies the file length (a hostile entry in a + // sparse header-read buffer, where the buffer holds the file's logical size but only a few + // kilobytes are loaded, would decode allocations no actual data backs). + let opts = ParseOptions { max_ifds: 64, max_depth: 3, value_budget: Some(1 << 20), ..Default::default() }; let try_parse = |kind: MakerNoteKind, ifd_at: u64, base: u64, ord: ByteOrder| -> Option { let (ifd, _) = parse_ifd_at(data, ifd_at, ord, base, false, &opts).ok()?; (!ifd.entries.is_empty()).then_some(MakerNote { kind, order: ord, base, ifd }) @@ -192,6 +196,35 @@ mod tests { assert_eq!(m.ifd.u64s(2).unwrap(), vec![9, 0]); } + /// A maker note's value budget is fixed, not computed from the length of the buffer it sits in: + /// an entry claiming more than the note budget (here 2 MB) is dropped, a fitting one is read. In a + /// sparse header read the buffer holds the file's logical size while only kilobytes were loaded, + /// so a length-scaled budget would let an unrelated entry decode megabytes of zeros (see the focus + /// reader, try_read_exif_with). + #[test] + fn a_note_entry_past_the_fixed_value_budget_is_dropped() { + let order = ByteOrder::Little; + let mut note = b"SONY DSC \0\0\0".to_vec(); + // the Sony IFD directly after the 12-byte header, one fitting BYTE entry and a decoy + // BYTE[2 MB] entry pointing into the file's own (zero-filled) body + order.put_u16(&mut note, 2); + order.put_u16(&mut note, 0x201b); + order.put_u16(&mut note, 1); + order.put_u32(&mut note, 1); + note.extend([3, 0, 0, 0]); + order.put_u16(&mut note, 0x9900); + order.put_u16(&mut note, 1); + order.put_u32(&mut note, 2 << 20); + order.put_u32(&mut note, 200); + order.put_u32(&mut note, 0); + let mut bytes = note.clone(); + bytes.resize(200 + (2 << 20), 0); // the decoy's value range is inside the real bytes + let m = parse_makernote(&bytes, 0, note.len() as u64, order, "SONY").unwrap(); + assert_eq!(m.kind, MakerNoteKind::Sony); + assert!(m.ifd.get(0x201b).is_some(), "the fitting entry is read"); + assert!(m.ifd.get(0x9900).is_none(), "the decoy is dropped by the value budget"); + } + #[test] fn garbage_note_is_none() { let (bytes, off, len) = with_note(ByteOrder::Little, vec![0xff; 40]); diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index bd728ca9c..70c66c5a7 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -338,6 +338,25 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { #[cfg(test)] mod tests { use super::*; + use crate::Value; + use crate::tags as t; + use crate::writer::{IfdBuilder, TiffWriter}; + + /// A value budget the caller sets bounds what is decoded: an entry claiming more value bytes than + /// the budget allows is dropped, entries that fit are read (the sparse header-read path, where the + /// buffer holds the file's logical size but only a few kilobytes were ever loaded, sizes its + /// budget from what was fetched, not from whatever length the file claims). + #[test] + fn the_value_budget_drops_over_budget_entries_and_keeps_the_rest() { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Keep".into())); + ifd0.set(0x9000, Value::Byte(vec![0; 2 << 20])); + let file = TiffWriter { order: ByteOrder::Little, bigtiff: false }.write(&[ifd0]).unwrap(); + let opts = ParseOptions { value_budget: Some(1 << 20), ..Default::default() }; + let tiff = Tiff::parse_with(&file, &opts).unwrap(); + assert_eq!(tiff.ifds[0].string(t::MAKE), Some("Keep".to_string()), "a fitting entry is read"); + assert!(tiff.ifds[0].get(0x9000).is_none(), "the over-budget entry is dropped"); + } /// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer /// `next`; a valid one-entry IFD sits at 16. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5a1e214dd..8e7b77f42 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] craft-ui = { workspace = true } +craft-window = { workspace = true } lightcraft-geom = { workspace = true } lightcraft-meta = { workspace = true } lightcraft-color = { workspace = true } @@ -30,6 +31,11 @@ log = { workspace = true } [dev-dependencies] # tests record why they skip (`cargo xtask ci` lists the skips) lightcraft-testkit = { workspace = true } +# The model-setup tests install and resume a real denoise model. Without this the engine's +# `default_loader` is the "this build cannot run denoise models" stub when the package is tested +# on its own (`cargo test -p lightcraft-ui-egui`); a workspace test only passed by feature +# unification with the desktop app's default `denoise` feature. +lightcraft-engine = { workspace = true, features = ["denoise"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } @@ -39,3 +45,6 @@ lightcraft-merge = { workspace = true } [build-dependencies] serde_json = { workspace = true } toml = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } diff --git a/crates/ui-egui/examples/thumb_request_bench.rs b/crates/ui-egui/examples/thumb_request_bench.rs index 675c920d5..34fe5fc88 100644 --- a/crates/ui-egui/examples/thumb_request_bench.rs +++ b/crates/ui-egui/examples/thumb_request_bench.rs @@ -113,7 +113,7 @@ fn micro(points: usize, pending: bool) { let key = app.session.thumb_job(id, 384).unwrap().key; app.renderer.textures.insert( Slot::Thumb(id), - Tex { key, photo: id, tex: texture.clone(), size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None }, + Tex { look: None, key, photo: id, tex: texture.clone(), size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None }, ); } request_thumb(&mut app, id, 384, 10); @@ -283,6 +283,7 @@ fn real() { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, if (i / 45) % 2 == 0 { -12.0 } else { 12.0 }), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, modifiers: Default::default(), }); let t = Instant::now(); diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 16095a43e..52ca94a52 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -47,6 +47,7 @@ "Add in place": "Přidat ze současného umístění", "Add in place: the library references the files where they are; nothing is copied or moved.": "Přidat ze současného umístění: knihovna odkazuje na soubory tam, kde jsou; nic se nekopíruje ani nepřesouvá.", "Add keyword": "Přidat klíčové slovo", + "Add Keywords": "Přidat klíčová slova", "Add number": "Přidat číslo", "Add to": "Přidat do", "Add to Album": "Přidat do alba", @@ -1193,6 +1194,7 @@ "New time": "Nový čas", "New…": "Nový…", "Next Appearance Mode": "Další režim vzhledu", + "Next Keyword Set": "Další sada klíčových slov", "Next Photo": "Další fotografie", "Next up for AI Denoise…": "Další na řadě pro redukci šumu pomocí AI…", "Night Blue": "Noční modř", @@ -1402,6 +1404,7 @@ "Preview size": "Velikost náhledu", "Preview unavailable": "Náhled není dostupný", "Previews": "Náhledy", + "Previous Keyword Set": "Předchozí sada klíčových slov", "Previous Photo": "Předchozí fotografie", "Primaries (x, y)": "Primární barvy (x, y)", "Print": "Tisk", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 679e3b00f..e2205db78 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -49,6 +49,7 @@ "Add in place": "Am Speicherort hinzufügen", "Add in place: the library references the files where they are; nothing is copied or moved.": "Am Speicherort hinzufügen: Die Bibliothek verweist auf die Dateien an ihrem aktuellen Speicherort; nichts wird kopiert oder verschoben.", "Add keyword": "Stichwort hinzufügen", + "Add Keywords": "Stichwörter hinzufügen", "Add number": "Nummer hinzufügen", "Add to": "Hinzufügen zu", "Add to Album": "Zu Album hinzufügen", @@ -950,6 +951,7 @@ "New name": "Neuer Name", "New time": "Neue Zeit", "New…": "Neu…", + "Next Keyword Set": "Nächster Stichwortsatz", "Next Photo": "Nächstes Foto", "Night Blue": "Nachtblau", "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon-NEFs beginnen mit einem Farb- und Tonwertbild, das wie bei Sony-ARWs an das Kamera-JPEG angepasst ist, statt mit einem gedämpften,", @@ -1112,6 +1114,7 @@ "Preview size": "Vorschaugröße", "Preview unavailable": "Vorschau nicht verfügbar", "Previews": "Vorschauen", + "Previous Keyword Set": "Vorheriger Stichwortsatz", "Previous Photo": "Vorheriges Foto", "Print": "Drucken", "Print…": "Drucken…", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index af22570ef..888af077f 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -34,6 +34,7 @@ "Add in place": "Añadir sin mover", "Add in place: the library references the files where they are; nothing is copied or moved.": "Añadir sin mover: la biblioteca hace referencia a los archivos donde están; no se copia ni se mueve nada.", "Add keyword": "Añadir palabra clave", + "Add Keywords": "Añadir palabras clave", "Add number": "Añadir número", "Add to": "Añadir a", "Add to Album": "Añadir al álbum", @@ -961,6 +962,7 @@ "New name": "Nuevo nombre", "New time": "Nueva hora", "New…": "Nuevo…", + "Next Keyword Set": "Conjunto de palabras clave siguiente", "Next Photo": "Foto siguiente", "Night Blue": "Azul nocturno", "No": "No", @@ -1117,6 +1119,7 @@ "Preview size": "Tamaño de la vista previa", "Preview unavailable": "Vista previa no disponible", "Previews": "Vistas previas", + "Previous Keyword Set": "Conjunto de palabras clave anterior", "Previous Photo": "Foto anterior", "Print": "Imprimir", "Print…": "Imprimir…", diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 6456a6624..e0a8c622a 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -58,6 +58,7 @@ "Add in place": "Lisää paikalleen", "Add in place: the library references the files where they are; nothing is copied or moved.": "Lisää paikalleen: kirjasto viittaa tiedostoihin niiden nykyisessä sijainnissa; mitään ei kopioida tai siirretä.", "Add keyword": "Lisää avainsana", + "Add Keywords": "Lisää avainsanoja", "Add number": "Lisää numero", "Add text overlay": "Lisää tekstipeittokuva", "Add to": "Lisää kohteeseen", @@ -1236,6 +1237,7 @@ "New…": "Uusi…", "Next Appearance Mode": "Seuraava ulkoasutila", "Next History Step": "Seuraava historiavaihe", + "Next Keyword Set": "Seuraava avainsanajoukko", "Next Photo": "Seuraava kuva", "Next Slide": "Seuraava dia", "Next page": "Seuraava sivu", @@ -1461,6 +1463,7 @@ "Preview unavailable": "Esikatselu ei käytettävissä", "Previews": "Esikatselut", "Previous History Step": "Edellinen historiavaihe", + "Previous Keyword Set": "Edellinen avainsanajoukko", "Previous Photo": "Edellinen kuva", "Previous Slide": "Edellinen dia", "Previous page": "Edellinen sivu", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 33a892ecf..3278d6ccd 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -34,6 +34,7 @@ "Add in place": "Ajouter sur place", "Add in place: the library references the files where they are; nothing is copied or moved.": "Ajouter sur place : la bibliothèque référence les fichiers là où ils se trouvent ; rien n'est copié ni déplacé.", "Add keyword": "Ajouter un mot-clé", + "Add Keywords": "Ajouter des mots-clés", "Add number": "Ajouter un numéro", "Add to": "Ajouter à", "Add to Album": "Ajouter à l'album", @@ -904,6 +905,7 @@ "New name": "Nouveau nom", "New time": "Nouvelle heure", "New…": "Nouveau…", + "Next Keyword Set": "Jeu de mots-clés suivant", "Next Photo": "Photo suivante", "Night Blue": "Bleu nuit", "No": "Non", @@ -1059,6 +1061,7 @@ "Preview size": "Taille de l'aperçu", "Preview unavailable": "Aperçu indisponible", "Previews": "Aperçus", + "Previous Keyword Set": "Jeu de mots-clés précédent", "Previous Photo": "Photo précédente", "Print": "Imprimer", "Print…": "Imprimer…", diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index abb6a732e..0d6da9b8f 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -58,6 +58,7 @@ "Add in place": "Tambahkan di lokasi asli", "Add in place: the library references the files where they are; nothing is copied or moved.": "Tambahkan di lokasi asli: pustaka merujuk file di lokasinya saat ini; tidak ada yang disalin atau dipindahkan.", "Add keyword": "Tambahkan kata kunci", + "Add Keywords": "Tambahkan kata kunci", "Add number": "Tambahkan nomor", "Add text overlay": "Tambahkan overlay teks", "Add to": "Tambahkan ke", @@ -1233,6 +1234,7 @@ "New…": "Baru…", "Next Appearance Mode": "Mode tampilan berikutnya", "Next History Step": "Langkah riwayat berikutnya", + "Next Keyword Set": "Set kata kunci berikutnya", "Next Photo": "Foto berikutnya", "Next Slide": "Slide berikutnya", "Next up for AI Denoise…": "Berikutnya dalam antrean pengurangan noise AI…", @@ -1445,6 +1447,7 @@ "Preview unavailable": "Pratinjau tidak tersedia", "Previews": "Pratinjau", "Previous History Step": "Langkah riwayat sebelumnya", + "Previous Keyword Set": "Set kata kunci sebelumnya", "Previous Photo": "Foto sebelumnya", "Previous Slide": "Slide sebelumnya", "Previous settings": "Pengaturan sebelumnya", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index a75448fbb..e159fd47d 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -34,6 +34,7 @@ "Add in place": "Aggiungi sul posto", "Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.", "Add keyword": "Aggiungi parola chiave", + "Add Keywords": "Aggiungi parole chiave", "Add number": "Aggiungi numero", "Add to": "Aggiungi a", "Add to Album": "Aggiungi all'album", @@ -907,6 +908,7 @@ "New name": "Nuovo nome", "New time": "Nuova ora", "New…": "Nuovo…", + "Next Keyword Set": "Set di parole chiave successivo", "Next Photo": "Foto successiva", "Night Blue": "Blu notte", "No": "No", @@ -1068,6 +1070,7 @@ "Preview size": "Dimensione dell'anteprima", "Preview unavailable": "Anteprima non disponibile", "Previews": "Anteprime", + "Previous Keyword Set": "Set di parole chiave precedente", "Previous Photo": "Foto precedente", "Print": "Stampa", "Print…": "Stampa…", @@ -2333,9 +2336,11 @@ "View Options": "Opzioni di visualizzazione", "Loupe View": "Vista lente", "Show Info Overlay": "Mostra sovrapposizione informazioni", + "Show message when loading or rendering photos": "Mostra un messaggio durante il caricamento o il rendering delle foto", "Loupe Info 1": "Info lente 1", "Loupe Info 2": "Info lente 2", "Use Defaults": "Usa predefiniti", + "Show briefly when photo changes": "Mostra brevemente al cambio di foto", "Camera & Lens": "Fotocamera e obiettivo", "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.", "Before/After Side by Side": "Prima/Dopo affiancati", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c3f11e6e5..e1834555b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -35,6 +35,7 @@ "Add in place": "現在の場所から追加", "Add in place: the library references the files where they are; nothing is copied or moved.": "現在の場所のファイルを参照します。コピーや移動はしません", "Add keyword": "キーワードを追加", + "Add Keywords": "キーワードを追加", "Add number": "番号を付ける", "Add to": "追加先", "Add to Album": "アルバムに追加", @@ -632,6 +633,7 @@ "Nested group": "入れ子のグループ", "New Album": "新しいアルバム", "New time": "新しい日時", + "Next Keyword Set": "次のキーワードセット", "Next Photo": "次の写真", "Night Blue": "ナイトブルー", "No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません", @@ -672,6 +674,7 @@ "Preview Build Progress": "プレビュー作成の進行状況", "Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中", "Preview unavailable": "プレビューを表示できません", + "Previous Keyword Set": "前のキーワードセット", "Previous Photo": "前の写真", "Profile Browser": "プロファイルブラウザー", "Profile Menu": "プロファイルメニュー", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 13fd48d3c..32c407700 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -110,6 +110,7 @@ "Add in place": "ადგილზე დამატება", "Add in place: the library references the files where they are; nothing is copied or moved.": "ადგილზე დამატება: ბიბლიოთეკა ფაილებს მათ ამჟამინდელ ადგილას მიმართავს; არაფერი კოპირდება ან გადაიტანება.", "Add keyword": "საკვანძო სიტყვის დამატება", + "Add Keywords": "საკვანძო სიტყვების დამატება", "Add number": "რიცხვის დამატება", "Add to": "დამატება:", "Add to Album": "ალბომში დამატება", @@ -1310,6 +1311,7 @@ "New…": "ახალი…", "Next Appearance Mode": "შემდეგი იერსახის რეჟიმი", "Next History Step": "ისტორიის შემდეგი ნაბიჯი", + "Next Keyword Set": "საკვანძო სიტყვების შემდეგი ნაკრები", "Next Photo": "შემდეგი ფოტო", "Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…", "Night Blue": "ღამის ლურჯი", @@ -1534,6 +1536,7 @@ "Previews": "წინასწარი ხედები", "Previews match exports on Mac screens": "წინასწარი ხედები ემთხვევა ექსპორტს Mac-ის ეკრანებზე", "Previous History Step": "ისტორიის წინა ნაბიჯი", + "Previous Keyword Set": "საკვანძო სიტყვების წინა ნაკრები", "Previous Photo": "წინა ფოტო", "Primaries (x, y)": "ძირითადი ფერები (x, y)", "Print": "ბეჭდვა", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index f03a81762..adac22e62 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -31,6 +31,7 @@ "Add in place": "Dodaj bez kopiowania plików", "Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.", "Add keyword": "Dodaj słowo kluczowe", + "Add Keywords": "Dodaj słowa kluczowe", "Add number": "Dodaj numer", "Add to": "Dodaj do", "Add to Album": "Dodaj do albumu", @@ -886,6 +887,7 @@ "New name": "Nowa nazwa", "New time": "Nowy czas", "New…": "Nowy…", + "Next Keyword Set": "Następny zestaw słów kluczowych", "Next Photo": "Następne zdjęcie", "Night Blue": "Nocny błękit", "No": "Nie", @@ -1030,6 +1032,7 @@ "Preview only": "Tylko podgląd", "Preview size": "Rozmiar podglądu", "Previews": "Podglądy", + "Previous Keyword Set": "Poprzedni zestaw słów kluczowych", "Previous Photo": "Poprzednie zdjęcie", "Print": "Drukuj", "Print…": "Drukuj…", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 88e955d26..e84fb7615 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -36,6 +36,7 @@ "Add in place": "Adicionar no local", "Add in place: the library references the files where they are; nothing is copied or moved.": "Adicionar no local: a biblioteca referencia os arquivos onde eles estão; nada é copiado nem movido.", "Add keyword": "Adicionar palavra-chave", + "Add Keywords": "Adicionar palavras-chave", "Add number": "Adicionar número", "Add to": "Adicionar a", "Add to Album": "Adicionar ao álbum", @@ -916,6 +917,7 @@ "New name": "Novo nome", "New time": "Novo horário", "New…": "Novo…", + "Next Keyword Set": "Próximo conjunto de palavras-chave", "Next Photo": "Próxima foto", "Night Blue": "Azul noturno", "No": "Não", @@ -1077,6 +1079,7 @@ "Preview size": "Tamanho da pré-visualização", "Preview unavailable": "Prévia indisponível", "Previews": "Pré-visualizações", + "Previous Keyword Set": "Conjunto de palavras-chave anterior", "Previous Photo": "Foto anterior", "Print": "Imprimir", "Print…": "Imprimir…", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5c8816c5e..306804df0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -20,6 +20,7 @@ "Add in place": "Добавить на месте", "Add in place: the library references the files where they are; nothing is copied or moved.": "Добавить на месте: библиотека ссылается на файлы там, где они лежат; ничего не копируется и не перемещается.", "Add keyword": "Добавить ключевое слово", + "Add Keywords": "Добавить ключевые слова", "Add number": "Добавить номер", "Add to": "Добавить в", "Add to Album": "Добавить в альбом", @@ -760,6 +761,7 @@ "New blank PDF": "Новый пустой PDF", "New name": "Новое имя", "New…": "Новый…", + "Next Keyword Set": "Следующий набор ключевых слов", "Next Photo": "Следующее фото", "Night Blue": "Ночной синий", "No": "Нет", @@ -897,6 +899,7 @@ "Preview size": "Размер просмотра", "Preview unavailable": "Просмотр недоступен", "Previews": "Просмотры", + "Previous Keyword Set": "Предыдущий набор ключевых слов", "Previous Photo": "Предыдущее фото", "Print": "Печать", "Print…": "Печать…", diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index d9af4b8e8..66e69b7ba 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -60,6 +60,7 @@ "Add in place": "Lägg till på plats", "Add in place: the library references the files where they are; nothing is copied or moved.": "Lägg till på plats: biblioteket refererar till filerna där de ligger; ingenting kopieras eller flyttas.", "Add keyword": "Lägg till nyckelord", + "Add Keywords": "Lägg till nyckelord", "Add Mask": "Lägg till mask", "Add number": "Lägg till nummer", "Add Object Click": "Lägg till objektklick", @@ -1362,6 +1363,7 @@ "New…": "Ny…", "Next Appearance Mode": "Nästa utseendeläge", "Next page": "Nästa sida", + "Next Keyword Set": "Nästa nyckelordsuppsättning", "Next Photo": "Nästa foto", "Next up for AI Denoise…": "Näst i tur för AI-brusreducering…", "Night Blue": "Nattblå", @@ -1599,6 +1601,7 @@ "Previews": "Förhandsgranskningar", "previews": "förhandsgranskningar", "Previous page": "Föregående sida", + "Previous Keyword Set": "Föregående nyckelordsuppsättning", "Previous Photo": "Föregående foto", "Previous settings": "Föregående inställningar", "Primaries (x, y)": "Primärfärger (x, y)", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 39090c0b3..ee694ed4f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -389,6 +389,7 @@ "Add in place": "Додати на місці", "Add in place: the library references the files where they are; nothing is copied or moved.": "Додати на місці: бібліотека посилається на файли в їхньому розташуванні; нічого не копіюється й не переміщується.", "Add keyword": "Додати ключове слово", + "Add Keywords": "Додати ключові слова", "Add number": "Додати число", "Add to Album": "Додати до альбому", "Add to Favorites": "Додати до улюблених", @@ -855,6 +856,7 @@ "Nested group": "Вкладена група", "New Album": "Новий альбом", "New time": "Новий час", + "Next Keyword Set": "Наступний набір ключових слів", "Next Photo": "Наступне фото", "Night Blue": "Нічна синява", "No named people in these photos": "На цих фото немає людей з іменами", @@ -887,6 +889,7 @@ "Presets and profiles": "Набори налаштувань і профілі", "Preview Build Progress": "Перебіг створення переглядів", "Preview only: editing the camera's embedded JPEG": "Лише перегляд: редагування вбудованого JPEG камери", + "Previous Keyword Set": "Попередній набір ключових слів", "Previous Photo": "Попереднє фото", "Profile Browser": "Оглядач профілів", "Profile Menu": "Меню профілів", diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index 98adc91b8..e4fb8feb8 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -60,6 +60,7 @@ "Add in place": "Thêm tại chỗ", "Add in place: the library references the files where they are; nothing is copied or moved.": "Thêm tại chỗ: thư viện tham chiếu tệp ở vị trí hiện tại; không sao chép hay di chuyển gì.", "Add keyword": "Thêm từ khóa", + "Add Keywords": "Thêm từ khóa", "Add number": "Thêm số", "Add to": "Thêm vào", "Add to Album": "Thêm vào album", @@ -1136,6 +1137,7 @@ "New…": "Mới…", "Next Appearance Mode": "Chế độ giao diện tiếp theo", "Next History Step": "Bước lịch sử sau", + "Next Keyword Set": "Bộ từ khóa tiếp theo", "Next Photo": "Ảnh tiếp theo", "Next up for AI Denoise…": "Tiếp theo cho AI Khử nhiễu…", "Night Blue": "Xanh đêm", @@ -1338,6 +1340,7 @@ "Preview unavailable": "Không có xem trước", "Previews": "Xem trước", "Previous History Step": "Bước lịch sử trước", + "Previous Keyword Set": "Bộ từ khóa trước", "Previous Photo": "Ảnh trước", "Primaries (x, y)": "Màu gốc (x, y)", "Print": "In", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 73caf6407..270af8a62 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -36,6 +36,7 @@ "Add in place": "在原位置添加", "Add in place: the library references the files where they are; nothing is copied or moved.": "在原位置添加:图库直接引用文件所在位置;不会复制或移动任何内容。", "Add keyword": "添加关键字", + "Add Keywords": "添加关键字", "Add number": "添加编号", "Add to": "添加到", "Add to Album": "添加到相册", @@ -981,6 +982,7 @@ "New blank PDF": "新建空白 PDF", "New name": "新名称", "New…": "新建…", + "Next Keyword Set": "下一个关键字集", "Next Photo": "下一张照片", "Night Blue": "夜色蓝调", "No": "否", @@ -1109,6 +1111,7 @@ "Preview only": "仅预览", "Preview size": "预览大小", "Previews": "预览", + "Previous Keyword Set": "上一个关键字集", "Previous Photo": "上一张照片", "Print": "打印", "Print…": "打印…", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 72cc3ab7f..0408e23ec 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -36,6 +36,7 @@ "Add in place": "原地加入", "Add in place: the library references the files where they are; nothing is copied or moved.": "原地加入:照片庫參照檔案目前的位置,不會複製或移動檔案。", "Add keyword": "新增關鍵字", + "Add Keywords": "新增關鍵字", "Add number": "加上編號", "Add to": "加入至", "Add to Album": "加入相簿", @@ -982,6 +983,7 @@ "New blank PDF": "新增空白 PDF", "New name": "新名稱", "New…": "新增…", + "Next Keyword Set": "下一個關鍵字集", "Next Photo": "下一張照片", "Night Blue": "夜色藍調", "No": "否", @@ -1110,6 +1112,7 @@ "Preview only": "僅供預覽", "Preview size": "預覽尺寸", "Previews": "預覽", + "Previous Keyword Set": "上一個關鍵字集", "Previous Photo": "上一張照片", "Print": "列印", "Print…": "列印…", diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 000000000..98f8458c9 --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,627 @@ +use super::clock::{AppClock, monotonic_ms}; +use super::services::Services; +use super::state::{GestureFrame, InjectedDrop, InjectedPress}; +use super::*; + +mod control; +mod frame; +mod input; +mod prefs; +mod screenshot; + +#[cfg(test)] +pub(crate) use frame::import_tick_allowed; +#[cfg(test)] +pub(crate) use screenshot::shot_ready; + +impl LightcraftApp { + /// Build the app with default UI, renderer, cache and control state, and move AI segmentation + /// to a worker so frames apply results instead of waiting. + pub fn new(mut session: Session, services: Services) -> Self { + // AI mask requests run on the model's worker; frames apply their results (never wait) + session.segmenter.background = true; + Self { + session, + ui: UiState::default(), + services, + renderer: render::Renderer::default(), + perf: Perf::default(), + caches: Caches::default(), + integrated_titlebar: false, + window_movable: None, + native_menu: false, + native_shortcuts: Default::default(), + recording_shortcut: None, + text_focus: false, + headless_host: false, + notices: vec![], + updates_rt: Default::default(), + quit_prompt: None, + notice_input: Default::default(), + quit_confirmed: false, + control_rx: None, + pending_screenshots: vec![], + pending_gestures: vec![], + layout_epoch: 0, + screenshot_token: 0, + shot_bytes: 0, + shot_geometry_epoch: 0, + last_shot_geometry: None, + max_texture_side: MAX_TEXTURE_SIDE, + clock: AppClock::new(), + accesskit_language: None, + slideshow_hidden_at: None, + slideshow_auto_paused: false, + dropped_files: vec![], + deferred_dropped_file_count: 0, + synthetic_in_flight: false, + ui_passes: 0, + #[cfg(test)] + library_passes: vec![], + shadow: None, + synthetic: vec![], + pointer_cleanup: false, + drop_hover: vec![], + drop_pending: vec![], + copied: None, + pending_picks: vec![], + synthetic_mods: egui::Modifiers::NONE, + synthetic_mods_release: false, + host_mods: egui::Modifiers::NONE, + styled: false, + font_language: i18n::Locale::En, + chinese_font: None, + fonts_ready: false, + last_time: 0.0, + canvas_rect: None, + image_rect: None, + grid_scroll: None, + film_scroll: None, + widgets: vec![], + widget_hits: vec![], + next_widgets: vec![], + next_widget_hits: vec![], + gesture: None, + brush_live: None, + loupe_shown: None, + region_view: None, + region_before_view: None, + size_hold: Default::default(), + drag_scale: Default::default(), + window_refused: None, + region_tiles: Default::default(), + compare_windows: [None; 2], + compare_tiles: Default::default(), + compare_hold_until: f64::NEG_INFINITY, + merge: merge::MergeState::default(), + import: None, + scan: None, + sync: None, + sync_owns_changes: false, + sync_run: None, + lightroom: None, + lightroom_last: None, + lightroom_result: None, + review_pending: None, + shown_library: None, + export: None, + tasks: Default::default(), + last_export_result: None, + slideshow_export_reported: true, + hover_preview: None, + window_is_fullscreen: false, + gpu_applied: None, + gpu_notice: false, + memory_applied: None, + display_applied: None, + display_error: None, + screen_profile: None, + screen_profile_read: None, + library_problem: None, + library_window_input: Default::default(), + alt_asks_for_library: false, + recent_checks: Default::default(), + activity_expanded: false, + model_setup: Default::default(), + } + } + + /// Supply the browser's separately downloaded Simplified Chinese font before the first frame. + pub fn with_chinese_font(mut self, bytes: Vec) -> Self { + if !bytes.is_empty() { + self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes))); + } + self + } + + /// Attach the control-channel receiver and keep CPU copies of photo textures, which headless + /// screenshots need; returns the app for chaining. + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + // keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them + self.renderer.keep_pixels = true; + self + } + + /// Seconds on the app's monotonic clock. Every timer that must advance while the window is + /// hidden (imports, autosave, playback, toasts, captures) uses this, never `i.time`. + pub fn tick_now(&self) -> f64 { + self.clock.now() + } + + /// The app clock in milliseconds, for control deadlines and timers. + fn tick_now_ms(&self) -> f64 { + self.clock.now() * 1000.0 + } + + /// Give a headless driver its simulated time (`None` returns to the monotonic host clock). + pub fn set_simulated_clock(&mut self, seconds: Option) { + self.clock.simulated = seconds.filter(|s| s.is_finite() && *s >= 0.0); + } + + /// The open person page, the way back to it, and the photo it was opened from follow a rename. + fn follow_renamed_person(&mut self, from: &str, to: &str) { + let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); + if self.ui.person_page.as_deref().is_some_and(same) { + self.ui.person_page = Some(to.to_string()); + } + if self.ui.last_person.as_deref().is_some_and(same) { + self.ui.last_person = Some(to.to_string()); + } + if let Some((Some(name), photo)) = self.ui.person_from.clone() + && same(&name) + { + self.ui.person_from = Some((Some(to.to_string()), photo)); + } + } + + /// Run a UI or engine command by id. The single entry point for every frontend path. + pub fn run(&mut self, id: &str, params: Value) -> Result { + self.run_typed(id, params).map_err(|e| e.message) + } + + /// [`LightcraftApp::run`], keeping what kind of error a refusal is: for code that decides on it + /// (offer a model's download, treat a cancel as one) instead of reading the message. + pub fn run_typed(&mut self, id: &str, params: Value) -> Result { + if let Some(result) = model_setup::intercept(self, id, ¶ms) { + return result.map_err(CommandError::from); + } + if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + if r.is_ok() { + docking::reveal_command(self, id)?; + } + return r.map_err(CommandError::from); + } + let params = if id == "crop.aspect" + && params.get("aspect").and_then(Value::as_str) == Some("toggle") + && self.crop_tool_aspect().is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + { + serde_json::json!({"aspect": "free"}) + } else { + params + }; + let r = self.session.execute(id, ¶ms).map_err(CommandError::from); + if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") { + self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") { + Some(serde_json::json!("original")) + } else { + self.session + .active() + .and_then(|photo| self.session.develop_of(photo)) + .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) + }; + } + if r.is_ok() && id == "library.toggleFilters" { + // the search field shows the text filter: empty while the filters are off, back with them + self.ui.search = self.session.filter.text.clone(); + } + if r.is_ok() && id == "library.clearFilter" { + // the search field shows the text filter: cleared from anywhere (the loupe's pill, the + // menu), it must not keep a search that no longer applies (issue #1648) + self.ui.search.clear(); + } + if r.is_ok() && id == "mask.adjust" { + // Judge local adjustments on the photo, without the selection overlay obscuring them. + // Keep it hidden after release; O / the overlay eye can show it again. + self.ui.mask_overlay = false; + } + if r.is_ok() && matches!(id, "mask.add" | "mask.addComponent" | "mask.duplicate" | "mask.select") { + // A new or newly picked mask shows its overlay again, as in Lightroom: hidden by a slider + // drag above, it stayed hidden while the Overlay menu still named a mode (issue #1800). + self.ui.mask_overlay = true; + } + if r.is_ok() { + // These commands finish synchronously, so their completion would otherwise + // be invisible once the activity stack is gone. Never show success on error. + let completed = match id { + "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), + "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Discard Preview Cache"))), + "develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))), + _ => None, + }; + if let Some(message) = completed { + let now = self.tick_now(); + self.ui.toast = Some((message, now + TOAST_SECONDS, None)); + } + } + if r.is_ok() && id == "photo.label" { + let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); + let text = match label { + Some(label) => { + let name = + self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into()); + i18n::tr_format!("{name} Label", name = name) + } + None => i18n::tr("Color label cleared").into(), + }; + let now = self.tick_now(); + self.ui.toast = Some((text, now + TOAST_SECONDS, label)); + } + match &r { + Err(e) => { + log::warn!("{id}: {e}"); + self.ui.status = e.message.clone(); + // no detector yet: offer it (its terms first) instead of only saying so + if id == "faces.detect" && e.kind == ErrorKind::ModelMissing(AiModel::FaceDetector) { + panels::faces::offer_detector(self); + } + } + Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v), + Ok(v) if id == "faces.renamePerson" => { + let to = v["to"].as_str().unwrap_or("").to_string(); + let from = params.get("from").and_then(Value::as_str).unwrap_or(""); + if !to.is_empty() { + self.follow_renamed_person(from, &to); + } + } + Ok(_) => {} + } + r + } + + /// The remembered ratio belongs to the tool until the user starts editing the crop. + pub(crate) fn crop_tool_aspect(&self) -> Option<(u32, u32)> { + let photo = self.session.active()?; + let d = self.session.develop_of(photo)?; + if d.crop != lightcraft_develop::Crop::default() { + return d.crop.aspect; + } + // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one + // Lock toggle (remembered as "free") unlocks it for good + let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); + match &remembered { + Value::String(s) if s == "original" => { + let p = self.session.catalog.photo(photo)?; + let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); + Some(if d.orientation.swaps_axes() { (h, w) } else { (w, h) }) + } + Value::Array(a) => { + let number = |i: usize| { + let v = a.get(i)?.as_f64()? * 100.0; + (v.is_finite() && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v.round() as u32) + }; + Some((number(0)?, number(1)?)) + } + _ => None, + } + } + + /// Called only after a crop gesture begins, inside its undo transaction. + pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it + let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); + if self.session.interaction.is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + && aspect != serde_json::json!("free") + // the remembered ratio, turned like the new photo's crop (as a named ratio is) + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) + { + // A remembered value the command refuses (a hand-edited settings file) would refuse + // every crop drag on an untouched photo: forget it and let this drag go ahead. + self.ui.crop_default_aspect = None; + self.ui.status = e.to_string(); + } + Ok(()) + } + + /// Advance a running slideshow, but not while the window is hidden: in 0.36 `i.time` froze + /// there, and a minimized show must not keep waking the event loop or requesting full-size + /// slide renders overnight. Playback is auto-paused on hide and resumed on unhide with the + /// slide's remaining time preserved. + fn slideshow_tick(&mut self, ctx: &egui::Context, now: f64) { + let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); + let Some(run) = self.ui.slideshow.as_mut() else { + self.slideshow_hidden_at = None; + self.slideshow_auto_paused = false; + return; + }; + if hidden { + if self.slideshow_hidden_at.is_none() { + self.slideshow_hidden_at = Some(now); + if !run.play.is_paused() { + run.play.toggle_pause(now); + self.slideshow_auto_paused = true; + } + } + return; + } + if self.slideshow_hidden_at.take().is_some() && self.slideshow_auto_paused { + self.slideshow_auto_paused = false; + if run.play.is_paused() { + run.play.toggle_pause(now); + } + } + panels::slideshow::tick(self, ctx, now); + } + + /// Announce the end of a Build Previews run. + fn preview_build_status(&mut self, ctx: &egui::Context) { + use std::sync::atomic::Ordering; + let Some(b) = self.session.preview_build.clone() else { return }; + let key = std::sync::Arc::as_ptr(&b) as usize; + if b.finished.load(Ordering::Relaxed) { + if self.ui.preview_build_seen != Some((key, true)) { + self.ui.preview_build_seen = Some((key, true)); + let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed)); + let plural = if done == 1 { "" } else { "s" }; + let mut msg = match (b.error(), b.what) { + (Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }), + (None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural), + (None, what) => { + crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what)) + } + }; + if failed > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed)); + } + self.toast(ctx, msg); + } + } else { + // The activity stack shows the run while it works; poll for the end. + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + } + + /// Announce when saving the library starts failing (changes then live only in memory and are + /// retried) and when it works again; the top bar's cloud icon shows the state meanwhile. + fn save_status(&mut self, ctx: &egui::Context) { + let unsaved = self.session.unsaved().map(|(n, e)| (n, e.to_string())); + match (unsaved, self.ui.unsaved_seen) { + (Some((n, e)), false) => { + self.ui.unsaved_seen = true; + let t = self.tick_now(); + self.ui.toast = Some(( + crate::i18n::tr_format!( + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry", + if n == 1 { "" } else { "s" }, + n = n, + e = e + ), + t + 6.0, + None, + )); + } + (None, true) => { + self.ui.unsaved_seen = false; + self.toast(ctx, crate::i18n::tr("Library saved")); + } + (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry + (None, false) => {} + } + } + + pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { + self.toast_for(ctx, text, TOAST_SECONDS); + } + + /// Auto straighten (the crop panel's Auto, a double-click with the Straighten tool), saying so + /// when it finds nothing to level by instead of leaving the photo as it is without a word. + pub fn auto_straighten(&mut self, ctx: &egui::Context) { + match self.run("crop.autoStraighten", serde_json::json!({})) { + Ok(r) if r.get("changed").and_then(Value::as_bool) == Some(false) => { + self.toast(ctx, crate::i18n::tr("No horizon or vertical lines to level by")); + } + Ok(_) => {} + Err(e) => self.toast(ctx, e), + } + } + + /// A toast that stays `secs` seconds (messages that say where to look or what to do next). + pub fn toast_for(&mut self, _ctx: &egui::Context, text: impl Into, secs: f64) { + let t = self.tick_now(); + self.ui.toast = Some((text.into(), t + secs, None)); + } + + /// Reverse geocoding: say how a place-names download ended (a failure stays readable, with what to do). + fn place_names_outcome(&mut self, ctx: &egui::Context) { + use lightcraft_engine::cmd::geocode::DownloadOutcome; + match self.session.place_names.take_outcome() { + Some(DownloadOutcome::Done(_)) => self.toast(ctx, crate::i18n::tr("Place names downloaded")), + Some(DownloadOutcome::Cancelled) => self.toast(ctx, crate::i18n::tr("Place names download cancelled")), + Some(DownloadOutcome::Failed(e)) => self.toast_error(ctx, e), + None if self.session.place_names.downloading() => ctx.request_repaint_after(std::time::Duration::from_millis(500)), + None => {} + } + } + + /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and + /// show their errors; start a zoomed-in detail pass once the clicking stops + /// (`ui.detail_due`); watch the model download. Never waits for the model. + fn ai_mask_detail(&mut self, ctx: &egui::Context, now: f64) { + let polled = self.session.segment_poll(); + if polled.changed { + ctx.request_repaint(); + } + if let Some(mask) = polled.refine { + // a new selection from a click or a description: show it, as `run` does for new masks + self.ui.mask_overlay = true; + self.ui.detail_due = Some((now + 0.3, mask)); + } + if let Some(e) = polled.messages.into_iter().last() { + self.ai_error(ctx, e, None); + } + let seg = &self.session.segmenter; + if seg.busy() || seg.pending_clicks().is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + let download = seg.download_status(); + if download.running { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + // a download finishing while its dialog is closed: say so once + if self.ui.sam_downloading && !download.running { + self.ui.sam_downloading = false; + let open = matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })); + match sam_download_ended(&download) { + _ if open => {} + Some((text, true)) => self.toast_error(ctx, text), + Some((text, false)) => self.toast(ctx, text), + None => {} + } + } + if let Some((due, mask)) = self.ui.detail_due { + if now >= due && !self.session.segmenter.busy() && !self.session.segmenter.detail_busy() { + self.ui.detail_due = None; + if let Err(e) = self.run("mask.refineDetail", serde_json::json!({"id": mask})) { + log::warn!("detail pass: {e}"); + } + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(150)); + } + } + if self.session.segmenter.detail_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + } + + /// An AI mask error: when the model isn't installed, the dialog that offers to download it + /// (`then`: the AI mask to start afterwards); otherwise a toast. Decided on the error's kind + /// ([`ErrorKind::ModelMissing`]), not its words. + pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into, then: Option<(&str, &str)>) { + let e = e.into(); + if lightcraft_engine::segment::Segmenter::AVAILABLE && e.kind == ErrorKind::ModelMissing(AiModel::Sam3) { + self.offer_sam_download(then); + } else { + self.toast_error(ctx, e.message); + } + } + + /// Open the dialog offering the SAM 3 download (never downloads by itself). + pub fn offer_sam_download(&mut self, then: Option<(&str, &str)>) { + if self.ui.dialog.is_none() || matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })) { + self.ui.dialog = Some(state::Dialog::SamModel { then: then.map(|(k, o)| (k.to_string(), o.to_string())), error: None }); + } + } + + /// A toast for an error the user has to read and act on (stays 6 s). + pub fn toast_error(&mut self, _ctx: &egui::Context, text: impl Into) { + let t = self.tick_now(); + self.ui.toast = Some((text.into(), t + 6.0, None)); + } + + /// The process is about to end (settings and library are saved by then): make sure nothing is + /// inside the GPU driver when it does (issue #620: a render running on a worker thread while + /// the process exits crashes in the driver). No GPU work starts from here on; the render + /// workers get up to `timeout` to finish their jobs, and what is left of it goes to GPU work + /// on other threads (export, denoise, the device warm-up). `false`: the deadline passed first + /// (the host exits anyway). The app renders nothing afterwards. + pub fn shutdown(&mut self, timeout: std::time::Duration) -> bool { + lightcraft_engine::gpu::begin_shutdown(); + #[cfg(not(target_arch = "wasm32"))] + let t0 = std::time::Instant::now(); + let stopped = self.renderer.shutdown(timeout); + #[cfg(not(target_arch = "wasm32"))] + let timeout = timeout.saturating_sub(t0.elapsed()); + let idle = lightcraft_engine::gpu::wait_idle(timeout); + stopped && idle + } + + /// Background and queue work. Called before layout on every host pass and, while the window is + /// hidden, from `Context::run_logic`. It may read the refreshed viewport fields only: never + /// `i.time`, events, keys or dropped files. + pub fn logic(&mut self, ctx: &egui::Context) { + i18n::set_language(self.ui.language); + // The fork's AccessKit root language and bidi runs follow the locale; presentation only. + // Compare against the cached string: `Context::language()` allocates on every call, and + // this runs on every UI pass and every hidden logic tick. + let language = self.ui.language.code(); + if self.accesskit_language.as_deref() != Some(language) { + ctx.set_language(Some(language.to_owned())); + self.accesskit_language = Some(language.to_owned()); + } + let t0 = monotonic_ms(); + self.note_max_texture_side(ctx); + self.note_shot_geometry(ctx); + self.ensure_fonts(ctx); + // Applying the theme belongs to logic: a host that only calls `logic` (and every hidden + // tick) must see an Appearance Mode / system-theme change, and the UI pass that follows + // then draws with the new visuals. `ui` keeps its own call for a settings change made by + // a command during the same pass; it is a no-op when the theme already matches. + self.sync_theme(ctx); + self.logic_background(ctx); + self.perf.logic_ms = monotonic_ms() - t0; + } + + /// Frame timings once layout is done (`t0`: when layout started). + fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { + if self.ui.frame_rate { + frame_rate::show(ctx, self); + } else { + frame_rate::forget(ctx); + } + panels::edit::wheel_pointer_frame_end(ctx); + ctx.output(|o| { + for c in &o.commands { + if let egui::OutputCommand::CopyText(text) = c { + self.copied = Some(text.clone()); + } + } + }); + self.perf.frame_ms = monotonic_ms() - t0; + self.perf.update_ms = self.perf.logic_ms + self.perf.frame_ms; + self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); + } + + /// Lay out the whole window: input/setup for the known UI phase, then presentation, then the + /// frame bookkeeping. A hidden `run_logic` never reaches this. + pub fn ui(&mut self, ui: &mut egui::Ui) { + i18n::set_language(self.ui.language); + let ctx = ui.ctx().clone(); + if !self.fonts_ready { + ctx.request_repaint(); + return; + } + self.sync_theme(&ctx); + self.ui_pass(&ctx); + self.draw_window(ui); + self.finish_ui_pass(&ctx); + } +} + +/// "Found 3 faces in 2 photos" for a `faces.detect` result. +pub(crate) fn detect_summary(v: &Value) -> String { + let photos = v["photos"].as_array().map(Vec::as_slice).unwrap_or_default(); + let counts: Vec = photos.iter().map(|p| p["faces"].as_array().map_or(0, Vec::len)).collect(); + let (faces, with) = (counts.iter().sum::(), counts.iter().filter(|n| **n > 0).count()); + let plural = |n: usize, w: &str| format!("{n} {w}{}", if n == 1 { "" } else { "s" }); + match (faces, photos.len()) { + (0, 1) => "No faces found".into(), + (0, n) => format!("No faces found in {}", plural(n, "photo")), + (f, 1) => format!("Found {}", plural(f, "face")), + (f, _) => format!("Found {} in {}", plural(f, "face"), plural(with, "photo")), + } +} + +/// What to say when a SAM 3 download ended while its dialog was closed: the text, and whether it is +/// shown as an error toast. A cancel is told by `cancelled`, not by the error's words. +pub(crate) fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> Option<(String, bool)> { + match &download.error { + Some(_) if download.cancelled => { + Some((crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time.").to_string(), false)) + } + Some(e) => Some((crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e), true)), + None if download.finished => Some((crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.").to_string(), true)), + None => None, + } +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs new file mode 100644 index 000000000..00b66e4b0 --- /dev/null +++ b/crates/ui-egui/src/app/control.rs @@ -0,0 +1,399 @@ +use super::*; +use crate::control; + +impl LightcraftApp { + /// Take the control receiver for this frame, run every queued request whose sender still + /// wants it, and reply directly or admit its screenshot/gesture to a later completed pass. + /// Requests abandoned by their sender are skipped rather than executed late. + pub(super) fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + // a request its sender gave up on (the window drew nothing for a while) is not run + // late: it would happen twice once the sender retries (issue #718) + if !req.take() { + continue; + } + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { target, headless } => { + if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) { + let _ = reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + control::Outcome::Gesture { method, params } => { + if let Err(error) = self.admit_gesture(ctx, method, params, reply.clone()) { + let _ = reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + } + } + self.control_rx = Some(rx); + } + + /// The navigation/document/modal/panel state that decides what an automation id means. + fn gesture_frame(&self) -> GestureFrame { + GestureFrame { + view: self.ui.view, + active: self.session.active(), + library: self.session.library_generation(), + dialog: self.ui.dialog.as_ref().map(std::mem::discriminant), + left_panel: self.ui.left_panel, + right: self.ui.right, + presets: self.ui.presets, + filmstrip: self.ui.filmstrip, + } + } + + /// Queue a synthetic-input request. Nothing is answered and no event is queued until a + /// completed layout newer than this admission exists (see [`Self::resolve_pending_gestures`]). + pub(crate) fn admit_gesture(&mut self, ctx: &egui::Context, method: String, params: Value, reply: Sender) -> Result<(), String> { + if self.pending_gestures.len() >= MAX_PENDING_GESTURES { + return Err(format!("{}: {MAX_PENDING_GESTURES} control gestures are already pending", method)); + } + let now = self.tick_now_ms(); + self.pending_gestures.push(PendingGesture { + method, + params, + reply, + admitted_epoch: self.layout_epoch, + frame: self.gesture_frame(), + deadline_ms: now + GESTURE_TIMEOUT_MS, + resolved_pass: None, + remaining_events: 0, + started: false, + target: None, + pressed: None, + result: Value::Null, + }); + ctx.request_repaint(); + Ok(()) + } + + /// Resolve one queued gesture against the fresh widgets/image rect of the pass that just + /// completed. A changed view/document/dialog/panel state cancels instead of retargeting. + pub(crate) fn resolve_pending_gestures(&mut self, ctx: &egui::Context) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + let frame = self.gesture_frame(); + let epoch = self.layout_epoch; + let mut gestures = std::mem::take(&mut self.pending_gestures); + let mut keep = Vec::with_capacity(gestures.len()); + for mut gesture in gestures.drain(..) { + if gesture.resolved_pass.is_some() { + // Already handed to the delivery queue; `reply_delivered_gestures` (or + // `expire_gestures`) owns its deadline and must remove its unsent events too. + keep.push(gesture); + continue; + } + if now > gesture.deadline_ms { + // Only this request failed to get a fresh layout. A later gesture has its own + // deadline and must not be canceled with it. + let _ = + gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); + continue; + } + if epoch <= gesture.admitted_epoch { + keep.push(gesture); + continue; + } + if gesture.frame != frame { + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the view, document, dialog or panel layout changed before a fresh layout", gesture.method), + })); + continue; + } + let before = self.synthetic.len(); + match control::gesture_events(self, ctx, &gesture.method, &gesture.params) { + Ok(outcome) => { + gesture.target = outcome.target; + gesture.result = outcome.result; + gesture.resolved_pass = Some(self.ui_passes); + gesture.remaining_events = self.synthetic.len().saturating_sub(before); + gesture.started = gesture.remaining_events == 0; + keep.push(gesture); + } + Err(error) => { + let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + } + self.pending_gestures = keep; + } + + /// Answer gestures whose last synthetic event a completed, nondiscarded UI pass consumed. + /// A zero-event action waits for one completed pass after resolution. + /// + /// Delivery does not compare the frame again: the gesture's own event may legitimately change + /// the view, document, dialog or panel (a click that opens a dialog, a key that starts Compare), + /// so a second snapshot would cancel exactly the input that did its job. Freshness is checked + /// before resolution ([`Self::resolve_pending_gestures`]) and again immediately before the + /// batch is injected ([`Self::drop_gestures_stale_before_injection`]). + pub(crate) fn reply_delivered_gestures(&mut self) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + // Cancel only the gestures that actually stalled: their unsent events are at the front of + // the queue, and gestures queued behind them are independent input that must survive. + let mut i = 0; + while i < self.pending_gestures.len() { + if self.pending_gestures[i].resolved_pass.is_none() || now <= self.pending_gestures[i].deadline_ms { + i += 1; + continue; + } + let at_head = self.head_gesture() == Some(i); + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, at_head); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} timed out before the UI consumed its last event", gesture.method), + })); + } + if !self.synthetic.is_empty() || self.synthetic_in_flight { + return; + } + let mut gestures = std::mem::take(&mut self.pending_gestures); + let mut keep = Vec::with_capacity(gestures.len()); + for mut gesture in gestures.drain(..) { + let Some(resolved) = gesture.resolved_pass else { + keep.push(gesture); + continue; + }; + if self.ui_passes <= resolved { + keep.push(gesture); + continue; + } + if gesture.remaining_events > 0 { + // A defensive queue reset (a non-head gesture owned unsent events) dropped this + // batch. It must not become an `ok: true` for input no panel ever saw. + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: its queued input was dropped before delivery", gesture.method), + })); + continue; + } + let result = std::mem::take(&mut gesture.result); + let _ = gesture.reply.send(serde_json::json!({"ok": true, "result": result})); + } + self.pending_gestures = keep; + } + + /// The gesture whose events sit at the head of the synthetic queue. + pub(super) fn head_gesture(&self) -> Option { + self.pending_gestures.iter().position(|gesture| gesture.resolved_pass.is_some() && gesture.remaining_events > 0) + } + + /// Fail resolved gestures whose view/document/dialog/panel snapshot changed before their + /// first synthetic batch reached the next completed pass. A hidden logic tick runs between + /// passes and may change what an automation id or a photo click means; fail closed instead of + /// delivering it against the new state. + /// + /// A widget gesture also has to keep aiming at the requested widget. egui attributes a click + /// to the widget under the press point in the *previous completed pass*, while the control + /// batch spans several host frames (a move, then press, then release). Recheck the target + /// before the first event and before every press; if it moved or disappeared, cancel this + /// gesture with an error instead of clicking whatever is now at those coordinates. + /// + /// Only the gesture at the head of the queue is checked, and later batches of the same gesture + /// may see its own partial effect (the first click of a double click selects the photo the + /// second one opens). A second, concurrently queued gesture is checked when its events reach + /// the head. + pub(super) fn drop_gestures_stale_before_injection(&mut self, ctx: &egui::Context) -> bool { + let Some(head) = self.head_gesture() else { return false }; + let first_batch = !self.pending_gestures[head].started; + if first_batch && self.pending_gestures[head].frame != self.gesture_frame() { + let gestures = std::mem::take(&mut self.pending_gestures); + for gesture in gestures { + if gesture.resolved_pass.is_none() { + self.pending_gestures.push(gesture); + } else { + if gesture.pressed.is_some() { + self.queue_pointer_cleanup(); + } + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the view, document, dialog or panel layout changed before delivery", gesture.method), + })); + } + } + self.synthetic.clear(); + return true; + } + let Some(target) = self.pending_gestures[head].target.clone() else { return false }; + let press_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: true, .. })); + let release_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: false, .. })); + // A drag legitimately ends outside the widget it started on; only a click must still find + // its target at the release that egui attributes the click to. A hover must still find a + // visible widget for its single pointer move. + let check_release = release_next && target.check_release; + let check_visible = first_batch && target.check_visible; + if !first_batch && !press_next && !check_release && !check_visible { + return false; + } + let latest = control::widget_rect(self, &target.id); + let latest_hit = control::widget_hit(self, &target.id); + // A press must go to the widget that owns the point. A click's release must too (a drag + // may legitimately end outside the widget it started on), so an overlay that appears + // between press and release cancels the click before the release rather than activating it. + // A hover's single move must also still find the widget visible: the documented contract + // rejects a clipped or newly covered target instead of moving the pointer at stale coordinates. + let must_own = press_next || (release_next && target.check_release) || check_visible; + // A quit prompt or settings notice owns input even before its Foreground areas have + // settled (egui's first layout pass is non-interactable), and even when a control request + // is resolved against the registry of that first pass. Only the prompt's own buttons pass. + let notice_ok = !must_own || crate::panels::notices::allows_target(self, latest_hit); + let still_aimed = latest.is_some_and(|rect| { + // A changed widget cancels any gesture before its first event. Owned presses, click + // releases and hovers must also be inside egui's clipped, visible part and be the + // topmost widget hit: a target scrolled out of its panel, or one under a non-modal + // overlay, must not be pressed or hovered at stale coordinates. + let geometry_ok = rect == target.rect || rect.contains(target.at); + geometry_ok && notice_ok && (!must_own || control::widget_target_visible(ctx, latest_hit, target.at)) + }); + if still_aimed { + return false; + } + let outside_registered = !target.rect.contains(target.at); + let reason = match latest { + None => "disappeared", + Some(rect) if !(rect == target.rect || rect.contains(target.at)) => "moved", + Some(_) if outside_registered => "has no visible interaction area at the requested point", + Some(_) => "is no longer visible", + }; + let phase = if first_batch { + "its first event" + } else if check_release { + "its release" + } else { + "its press" + }; + // Only this gesture's unsent events are removed: they are at the front of the queue, and + // gestures resolved after it are checked in turn when they reach the head. Failing the + // whole click would also discard unrelated input. + let gesture = self.pending_gestures.remove(head); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: widget `{}` {reason} before {phase}; nothing was retargeted", gesture.method, target.id), + })); + true + } + + /// Remove a canceled gesture's unsent events from the front of the queue and release a pointer + /// button it left down. Callers cancel in queue order, so the events removed are exactly this + /// gesture's unsent prefix; a later gesture's events stay queued. + pub(super) fn cancel_gesture_events(&mut self, gesture: &PendingGesture, at_head: bool) { + if at_head { + let unsent = gesture.remaining_events.min(self.synthetic.len()); + self.synthetic.drain(..unsent); + } else if gesture.remaining_events > 0 { + // The queue order makes only the head own unsent events. If a caller ever breaks that + // invariant, fail safe: stale synthetic input must not reach an unrelated widget, even + // at the cost of dropping the rest of this batch instead of panicking or guessing a + // prefix. `reply_delivered_gestures` fails any gesture whose events disappeared this + // way instead of reporting a false success. + log::warn!("canceled non-head gesture still owned {} unsent synthetic events; dropping the queue", gesture.remaining_events); + self.synthetic.clear(); + self.synthetic_in_flight = false; + } + if gesture.pressed.is_some() { + self.queue_pointer_cleanup(); + } + } + + /// Cancel every synthetic gesture that a newly shown prompt or blocking library window makes + /// unsafe. + /// + /// A gesture whose input is still queued must not be delivered under the barrier or later when + /// it is gone. A gesture whose press already reached egui must be released without a click. A + /// gesture that already delivered all its events may still complete its reply: the barrier may + /// well be its own result (e.g. a key that opened the quit prompt). `all` is for a batch already + /// injected this host frame before the barrier was discovered after `raw_input_hook`: the + /// barrier filter drops it before any panel sees it, so even a fully injected gesture must fail + /// rather than report a successful delivery. + pub(crate) fn cancel_gestures_for_barrier(&mut self, all: bool, reason: &str) { + // All not-yet-injected events belong to the queue's head, one gesture at a time. Cancel + // them in order so only that gesture's unsent prefix is removed. + while let Some(head) = self.head_gesture() { + let gesture = self.pending_gestures.remove(head); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), + })); + } + let mut i = 0; + while i < self.pending_gestures.len() { + if all || self.pending_gestures[i].resolved_pass.is_none() || self.pending_gestures[i].pressed.is_some() { + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, false); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), + })); + } else { + i += 1; + } + } + } + + /// Cancel a press that reached egui without making it a click or starting a drag. The reset + /// itself happens before egui's next input pass in [`Self::raw_input_hook`], where replacing + /// the pointer state and stopping the drag is safe (the same approach as the sibling + /// SoundCraft/VectorCraft apps). No pointer movement is injected: moving beyond + /// `max_click_dist` suppresses a click but can also start a drag on a slider or curve. + pub(super) fn queue_pointer_cleanup(&mut self) { + self.pointer_cleanup = true; + } + + /// Apply a queued pointer cancellation before egui processes the next input pass. + pub(super) fn apply_pointer_cleanup(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + if !std::mem::take(&mut self.pointer_cleanup) { + return; + } + ctx.input_mut(|input| input.pointer = egui::PointerState::default()); + ctx.stop_dragging(); + if self.synthetic_mods != egui::Modifiers::NONE { + // The cleanup removes only the app's synthetic modifiers, never a real host change. + // A host `ModifiersChanged` in this same raw batch is already authoritative and was + // recorded before this call; otherwise restore the host's last known state (which may + // be a physically held Shift), rather than clearing it unconditionally with NONE. + let host_changed = raw.events.iter().any(|event| matches!(event, egui::Event::ModifiersChanged(_))); + self.synthetic_mods = egui::Modifiers::NONE; + if !host_changed { + raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); + } + } + self.synthetic_mods_release = false; + } + + /// Fail pending gestures at the monotonic deadline even when no UI pass runs (a hidden window + /// whose paint request the host ignored). `logic` calls this every tick. + pub(super) fn expire_gestures(&mut self) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + if !self.pending_gestures.iter().any(|gesture| now > gesture.deadline_ms) { + return; + } + // Cancel exactly the stalled requests, in queue order, so a later gesture's input survives. + let mut i = 0; + while i < self.pending_gestures.len() { + if now <= self.pending_gestures[i].deadline_ms { + i += 1; + continue; + } + let at_head = self.head_gesture() == Some(i); + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, at_head); + let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); + } + } +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs new file mode 100644 index 000000000..efae98a9b --- /dev/null +++ b/crates/ui-egui/src/app/frame.rs @@ -0,0 +1,454 @@ +use super::*; + +impl LightcraftApp { + /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. + pub(super) fn ensure_fonts(&mut self, ctx: &egui::Context) { + if !self.styled { + theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); + theme::apply(ctx); + Self::drop_legacy_panel_state(ctx); + // File → Add from Device lists cards scanned in the background: show hot-plugs + let repaint = ctx.clone(); + lightcraft_engine::devices::on_change(move || repaint.request_repaint()); + // "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a + // worker fills: a sleeping NAS or a dropped share never stalls a frame + let repaint = ctx.clone(); + self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); + self.styled = true; + self.font_language = self.ui.language; + } else if self.font_language != self.ui.language { + // Shared Han characters take the active language's forms (Japanese faces for 日本語, + // the Simplified Chinese face for 简体中文): rebuild the fallback order. + theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); + self.font_language = self.ui.language; + } else { + self.fonts_ready = true; + } + } + + /// 0.36 stored each `PanelState` under the panel's exact `Id`; 0.37 stores it under + /// `parent_ui.scope_id().with_salt(id_salt)`, so the old entries are unreachable and the + /// panels reset once by simply not finding them. Remove the old keys anyway so egui memory + /// does not retain dead entries (no document, workspace JSON, catalog or XMP data is + /// migrated; nothing above a user's photo edits depends on them). + fn drop_legacy_panel_state(ctx: &egui::Context) { + for salt in ["top_bar", "bottom_bar", "tool_strip", "library-problem-banner"] { + ctx.data_mut(|d| d.remove::(egui::Id::unique(salt))); + } + } + + /// Queues and timers. Each consumed queue is drained once per call; time-gated work uses the + /// monotonic app clock, so a hidden `run_logic` keeps it advancing and a second layout pass is + /// a no-op rather than a duplicate. + pub(super) fn logic_background(&mut self, ctx: &egui::Context) { + panels::notices::logic(self); + // closing the window (or Quit) with changes only in memory: retry, else ask first + if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + let now = self.tick_now(); + self.drain_control(ctx); + self.apply_settings(ctx); + if self.queued_input_needs_repaint() { + ctx.request_repaint(); + } + self.renderer.poll(ctx, &mut self.session); + merge::poll(self, ctx); + import::poll_scan(self, ctx); + sync::poll(self, ctx); + sync::poll_run(self, ctx); + // Import commits batches and must not run twice in one host frame. The first layout pass + // and a hidden logic tick both report pass index 0; a second pass reports 1. A wall-clock + // gate cannot express that (a slow discarded first pass would re-run it). + if import_tick_allowed(ctx) { + import::tick(self, ctx); + } + lightroom_import::tick(self, ctx); + recheck::tick(self); + tasks::poll(self, ctx); + updates_ui::tick(self, ctx); + self.preview_build_status(ctx); + self.save_status(ctx); + self.ai_mask_detail(ctx, now); + self.place_names_outcome(ctx); + self.slideshow_tick(ctx, now); + panels::denoise::pump_background(self, ctx, now); + panels::faces::pump_background(self, ctx, now); + model_setup::pump(self, ctx, now); + focus_scan::pump(self, ctx, now); + // auto import: list the watched folder every few seconds (on a worker thread: it may be on + // a network share), then import what's new like any import (also on a worker thread) + #[cfg(not(target_arch = "wasm32"))] + if let Some(folder) = self.session.import_defaults.auto_folder.clone() { + const LABEL: &str = "Auto Import"; + if now - self.ui.auto_import_at >= 3.0 + && self.import.is_none() + && self.sync_run.is_none() + && self.lightroom.is_none() + && !self.tasks.is_running(LABEL) + { + self.ui.auto_import_at = now; + let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); + let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { + let listing = match listing { + Ok(l) => l, + Err(e) => return log::debug!("auto import: {e}"), + }; + let p = serde_json::json!({"listing": listing, "start": false}); + let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return }; + let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return }; + let paths: Vec = + params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); + if let Some(o) = params.as_object_mut() { + o.remove("paths"); + } + let undo0 = app.session.undo.len(); + app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); + }; + // listed every few seconds: quiet, no row in the activity stack + if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { + log::warn!("{e}"); + } + } + ctx.request_repaint_after(std::time::Duration::from_secs(3)); + } + // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files + // are reachable, and said once when they start waiting and once when they're written + #[cfg(not(target_arch = "wasm32"))] + { + if self.session.unsaved_sidecars() > 0 { + if now - self.ui.sidecar_retry_at >= 10.0 { + self.ui.sidecar_retry_at = now; + self.session.retry_unsaved_sidecars(); + } + ctx.request_repaint_after(std::time::Duration::from_secs(10)); + } + let waiting = self.session.unsaved_sidecars(); + if waiting > 0 && self.ui.sidecars_waiting == 0 { + self.toast_for( + ctx, + crate::i18n::tr_format!( + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + n = waiting + ), + 8.0, + ); + } else if waiting == 0 && self.ui.sidecars_waiting > 0 { + self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); + } + self.ui.sidecars_waiting = waiting; + } + // Settings → Import: open the import review when a camera or card is connected + #[cfg(not(target_arch = "wasm32"))] + if self.session.import_defaults.show_on_card { + if let Some(now) = lightcraft_engine::devices::devices_scanned() { + import::cards_seen(self, now); + } + ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); + } else { + self.ui.known_cards = None; + } + self.session.persist_if_dirty(); + self.expire_gestures(); + self.collect_screenshots(ctx); + self.issue_screenshots(ctx); + self.request_paint_while_hidden(ctx); + } + + /// While hidden, queued captures and an injected gesture step need a real UI pass; a pending + /// screenshot command paints by itself once issued. + fn request_paint_while_hidden(&self, ctx: &egui::Context) { + // `logic` runs per layout pass; one command per host frame is enough. + if ctx.current_pass_index() != 0 { + return; + } + // `ViewportInfo::visible()`: minimized or fully occluded is `Some(false)`; `None` + // (headless, `RawInput::default()`) is normal and must not be treated as hidden. + let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); + if hidden && self.needs_ui_pass_while_hidden() { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + } + + /// A hidden window needs a real UI pass for queued/settling captures, for control gestures + /// that still have to resolve or deliver, and for an injected synthetic step. An already + /// issued screenshot has its own compositor-driven paint and must not request another + /// `RequestPaintWhileHidden`; its fallback and timeout keep running from `logic`. + pub(crate) fn needs_ui_pass_while_hidden(&self) -> bool { + !self.pending_gestures.is_empty() + || self.pointer_cleanup + || self.pending_screenshots.iter().any(|shot| shot.sent_at_ms.is_none()) + || !self.synthetic.is_empty() + || self.synthetic_in_flight + } + + /// Queued synthetic input, including a queued pointer cancellation that is not part of + /// `synthetic`, needs the next visible frame even when an earlier stall left the synthetic + /// queue empty. + pub(crate) fn queued_input_needs_repaint(&self) -> bool { + !self.synthetic.is_empty() || self.pointer_cleanup + } + + /// Input, layout and setup for a known UI phase: at most once per host frame, and never from a + /// hidden logic tick. `current_pass_index() == 0` is the first pass of this `run_ui`; a second + /// pass has empty events. + pub(super) fn ui_pass(&mut self, ctx: &egui::Context) { + if ctx.current_pass_index() != 0 { + return; + } + // A prompt owns the keyboard and focus. A notice may have been added by this frame's logic + // after `raw_input_hook`; capture its Tab/Enter/Space/Escape and drop everything else + // before any panel, focused control or shortcut reads the input. A new prompt gets a + // shielding pass whose background is not drawn at all (see `draw_window`). + let batch_in_flight = self.synthetic_in_flight; + if panels::notices::prepare_ui_pass(self, ctx) { + self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); + } + panels::library_problem::prepare_ui_pass(self, ctx, batch_in_flight); + let now = self.tick_now(); + // the last pass's time is fine here: this is a UI phase, not a hidden tick + let t = ctx.input(|i| i.time); + let dt = t - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = t; + panels::denoise::pump_ui_input(self, ctx, now); + panels::faces::pump_ui_input(self, ctx, now); + // A prompt or the blocking library window owns the library too: files dropped while either + // is up wait in the queue and are imported on the first UI pass after it goes away, never + // behind it. With no library open the window's `hold_dropped` path still takes the photos + // once its own dialog and asynchronous library picker are gone, so `blocks_file_drops` + // only holds them back until then. Mark those drops: once the barrier opens they may find + // a dialog, Lightroom import or import that the library's own open started still running. + if panels::notices::blocks_input(self) || panels::library_problem::blocks_file_drops(self) { + self.defer_dropped_files(); + } else { + self.import_dropped_files(ctx); + } + self.check_external_editor(ctx); + // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI + // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). + panels::library_problem::logic(self, ctx); + if self.fonts_ready && !updates_ui::blocks_input(self) { + shortcuts::handle(self, ctx); + } + } + + /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / + /// Survey area) in black. Painted above the panels, below dialogs and notices. + fn lights_out(&self, ctx: &egui::Context) { + let a = self.ui.lights_out.veil(); + if a <= 0.0 { + return; + } + let screen = ctx.content_rect(); + let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; + let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lights-out"))); + let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); + if !k.is_positive() { + p.rect_filled(screen, 0.0, veil); + return; + } + // four bands around the photo + use egui::{Rect, pos2}; + p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); + } + + /// Once per library opened (at launch too): name it in the window title, so nobody edits in + /// the wrong one, and put it first in File ▸ Open Recent Library. + pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { + let generation = self.session.library_generation(); + if self.shown_library == Some(generation) { + return; + } + self.shown_library = Some(generation); + if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { + let path = l.dir.to_string_lossy().into_owned(); + panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + // it is open: whatever an earlier check said about it is over + self.recent_checks.opened(&path); + // when, for the launch window (issue #1290); only the listed libraries keep a time + let settings = &mut self.ui.settings; + settings.recent_opened.insert(path, (self.session.clock)()); + settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); + } + + /// "LightCraft — " (just "LightCraft" for a library that isn't saved). + pub fn window_title(&self) -> String { + match panels::settings::open_library_name(self) { + Some(name) => format!("LightCraft — {name}"), + None => "LightCraft".to_string(), + } + } + + /// The browser page's title: "[ •] — LightCraft" for the active photo (• while + /// `unsaved`: changes not yet written to storage), else the window title. The web build has no + /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which + /// document a drop opened and whether it is saved (issue #2397). + pub fn page_title(&self, unsaved: bool) -> String { + let dot = if unsaved { " •" } else { "" }; + match self.session.active().and_then(|id| self.session.catalog.photo(id)) { + Some(p) => format!("{}{dot} — LightCraft", p.file_name), + None => format!("{}{dot}", self.window_title()), + } + } + + /// A completed, nondiscarded pass is a settled frame; a discarded pass threw its widgets away + /// and must not advance the geometry generation a capture binds to. + pub(crate) fn finish_ui_pass(&mut self, ctx: &egui::Context) { + // Any pass consumed the injected batch; a discarded pass still read its events and the + // next pass has none. Only a nondiscarded pass, however, is a settled frame a capture may + // bind to and a control gesture may resolve against. + self.synthetic_in_flight = false; + if ctx.will_discard() { + self.next_widgets.clear(); + self.next_widget_hits.clear(); + return; + } + self.widgets = std::mem::take(&mut self.next_widgets); + self.widget_hits = std::mem::take(&mut self.next_widget_hits); + panels::notices::finish_ui_pass(self, ctx); + panels::library_problem::finish_ui_pass(self, ctx); + self.ui_passes = self.ui_passes.wrapping_add(1); + self.layout_epoch = self.layout_epoch.wrapping_add(1); + self.resolve_pending_gestures(ctx); + self.reply_delivered_gestures(); + if self.pointer_cleanup { + // A canceled press may be discovered by a timeout after the synthetic queue emptied; + // nothing else may ask for the frame that applies its cancellation. + ctx.request_repaint(); + } + } + + /// Presentation only: the window's panels, dialogs and overlays. + pub(super) fn draw_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let t0 = monotonic_ms(); + panels::library_problem::begin_draw(self); + // A command or shortcut drawn before this can create the window after `ui_pass`; catch it + // here before any panel draws with the still-focused background text field. + let batch_in_flight = self.synthetic_in_flight; + panels::library_problem::prepare_ui_pass(self, &ctx, batch_in_flight); + // A newly shown blocking library window gets the same barrier as a prompt: its first Area + // layout is non-interactable and hit testing still uses the previous pass, so background + // panels (and a focused text field) must not draw in that pass. + if panels::library_problem::shield_pass(self, &ctx) { + #[cfg(test)] + self.library_passes.push(panels::library_problem::LibraryPass::Shield); + panels::library_problem::show(self, &ctx, false); + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + ctx.request_discard("library problem: shield the window's creation frame"); + self.end_frame(&ctx, t0); + return; + } + // A newly shown prompt gets one pass with no background. egui's first Area layout is + // non-interactable and its hit test uses the previous pass, so drawing panels here would + // let a pointer press/release in the prompt's creation frame act underneath it. `ui()` then + // reruns this pass with empty raw input, where the settled prompt and the full window draw + // together. This is a state/input-routing barrier, not a wholesale input disable: once + // settled, raw pointer input still reaches the prompt backdrop (a no-op) or its buttons. + if panels::notices::shield_pass(self, &ctx) { + panels::notices::show(self, &ctx); + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + ctx.request_discard("notice: shield the prompt's creation frame"); + self.end_frame(&ctx, t0); + return; + } + #[cfg(test)] + self.library_passes.push(panels::library_problem::LibraryPass::Background); + panels::dialogs::prepare(self, ui); + // Freeze this frame's notice barrier before `notices::show` can dismiss a prompt: the same + // click or key must never be acted on again by the dialog drawn after it. + let notice_blocks = panels::notices::blocks_input(self); + if std::mem::take(&mut self.ui.quit) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + self.library_changed(&ctx); + // panels set it again this frame while the pointer rests on a preset or profile + self.hover_preview = None; + if self.ui.fullscreen { + // full-screen preview: the photo alone on black + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); + panels::second::show(self, &ctx); + if notice_blocks { + panels::notices::restore_prompt_pointer(self, &ctx); + } + panels::notices::show(self, &ctx); + if notice_blocks { + panels::notices::hide_background_pointer(&ctx); + } + panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); + panels::library_problem::show(self, &ctx, notice_blocks); + panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); + if notice_blocks { + // Keep the real press/release state for the next pass: an ordinary click spans + // host frames, and resetting it above must only hide it from this pass's dialogs. + panels::notices::restore_prompt_pointer(self, &ctx); + } + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + self.end_frame(&ctx, t0); + return; + } + // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, + // right panels and left panel run to the bottom; the bottom bar sits between them). + panels::topbar::show(self, ui); + panels::library_problem::banner(self, ui); + panels::strip::show(self, ui); + // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour + let chrome = theme::Tokens::get(&ctx).chrome; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); + self.lights_out(&ctx); + panels::second::show(self, &ctx); + if notice_blocks { + panels::notices::restore_prompt_pointer(self, &ctx); + } + panels::notices::show(self, &ctx); + if notice_blocks { + panels::notices::hide_background_pointer(&ctx); + } + panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); + panels::library_problem::show(self, &ctx, notice_blocks); + export_task::poll(self, &ctx); + slideshow_export::poll(self, &ctx); + panels::activity::show(self, &ctx); + export_task::poll_photos(self, &ctx); + pick::poll(self, &ctx); + panels::grid::drag_feedback(self, &ctx); + panels::left::album_drag_feedback(self, &ctx); + panels::left::section_drag_feedback(self, &ctx); + panels::keyword_list::drag_feedback(self, &ctx); + panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); + // AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't + // movable: it stops being movable while the pointer is on a bar control or a panel over it + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable); + if notice_blocks { + // Keep the real press/release state for the next pass: an ordinary click spans host + // frames, and resetting it above must only hide it from this pass's dialogs. + panels::notices::restore_prompt_pointer(self, &ctx); + } + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + self.text_focus = ctx.egui_wants_keyboard_input(); + self.end_frame(&ctx, t0); + } +} + +/// Import commits batches at most once per host frame. The first layout pass and a hidden +/// `run_logic` tick both report pass index 0; a discarded frame's later passes report 1+. A +/// wall-clock gate cannot express that (a slow first pass could re-run it within one frame). +pub(crate) fn import_tick_allowed(ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 +} diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs new file mode 100644 index 000000000..f94108ce0 --- /dev/null +++ b/crates/ui-egui/src/app/input.rs @@ -0,0 +1,301 @@ +use super::*; + +impl LightcraftApp { + /// Preserve the currently queued handles while a prompt or blocking library window owns + /// drops. Hosts call this in their UI phase even if the app is not ready to draw yet. + pub fn defer_dropped_files(&mut self) { + self.deferred_dropped_file_count = self.dropped_files.len(); + } + + /// Take the host's dropped files, exactly once. The queue is filled by `raw_input_hook`, so a + /// hidden logic tick can never replay the last pass's drop. Web hosts call this in their UI + /// phase (they need `bytes_async`, which is not available on a native file path). + pub fn take_dropped_files(&mut self) -> Vec { + self.deferred_dropped_file_count = 0; + std::mem::take(&mut self.dropped_files) + } + + /// Take only drops that did not wait behind a prompt or blocking library window. + /// Hosts call this in the UI phase to refuse fresh arrivals behind an ordinary dialog + /// while preserving the earlier protected prefix for a later pass. + pub fn take_unprotected_dropped_files(&mut self) -> Vec { + if self.deferred_dropped_file_count == 0 { + return self.take_dropped_files(); + } + self.dropped_files.split_off(self.deferred_dropped_file_count.min(self.dropped_files.len())) + } + + /// Import native drops as presets, face models or photos. + pub(super) fn import_dropped_files(&mut self, ctx: &egui::Context) { + // A drop that waited behind a prompt or the library window is not dropped again just + // because a dialog, Lightroom catalog import, a running Synchronize Folder or the import + // a library open started is still running when the barrier goes. Keep it queued and try + // the next pass. A drop made outside the barrier keeps the deliberate "close the + // dialog"/"An import is running" refusal below. + if self.deferred_dropped_file_count > 0 + && (self.ui.dialog.is_some() || self.lightroom.is_some() || self.sync_run.is_some() || import::running(self)) + { + // Only the earlier barrier-held prefix waits. Fresh drops must still be handled + // under the current dialog/import policy instead of inheriting that protection. + #[cfg(not(target_arch = "wasm32"))] + { + let fresh = self.take_unprotected_dropped_files(); + self.handle_dropped_files(ctx, fresh); + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + return; + } + let dropped = self.take_dropped_files(); + if dropped.is_empty() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + self.handle_dropped_files(ctx, dropped); + #[cfg(target_arch = "wasm32")] + let _ = (dropped, ctx); + } + + /// Files dropped on the window: face models open their licence dialog, presets import as + /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). + #[cfg(not(target_arch = "wasm32"))] + fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec) { + let dropped: Vec = + dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect(); + if dropped.is_empty() { + return; + } + // a dialog is on screen: the review would replace it, losing what it holds + if self.ui.dialog.is_some() { + self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); + return; + } + let drop = drop_overlay::classify(dropped); + if let Some(model) = drop.models.first() { + // no library open yet (the launch list, a library that failed to open): its licence + // dialog would open behind the window that owns input. The drop is refused like a + // preset; photos below still wait for the library chosen. + if panels::library_problem::holds_drops(self) { + self.toast(ctx, crate::i18n::tr("Open a library first, then drop the model again")); + } else if let Err(e) = panels::faces::open_dialog(self, model) { + self.toast(ctx, e); + } + } + if !drop.presets.is_empty() { + // presets live in a library: with none open they would go with the session it replaces + if panels::library_problem::holds_drops(self) { + self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); + } else { + let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); + } + } + if drop.photos.is_empty() { + return; + } + // no library open yet (the launch list, a library that failed to open): they wait for one + let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; + if self.lightroom.is_some() { + self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); + return; + } + if import::running(self) { + self.toast(ctx, crate::i18n::tr("An import is running")); + return; + } + let started = if self.ui.settings.import_dropped_directly { + // read and added on a worker thread (dropped folders can be large, or on a slow drive) + import::start_paths(self, photos).map(|_| ()) + } else { + import::open(self, photos).map(|_| ()) + }; + if let Err(e) = started { + self.toast(ctx, e); + } + } + + /// A file dropped by the host, kept for the next UI pass (web hosts consume their own queue). + fn queue_dropped_files(&mut self, raw: &mut egui::RawInput) { + if !raw.dropped_files.is_empty() { + self.dropped_files.append(&mut raw.dropped_files); + } + } + + /// Take the host's dropped files once and inject synthetic events (pointer events one per + /// frame; key sequences up to the release). + /// + /// The hook also runs before a hidden logic-only tick, so it injects at most one batch and + /// keeps that batch in flight until a completed UI pass consumed it. It never injects while + /// the fonts are not installed (the pass would draw nothing and the events would be lost). + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); + if !self.drop_pending.is_empty() { + raw.dropped_files = std::mem::take(&mut self.drop_pending) + .into_iter() + .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) + .collect(); + } + if !self.drop_hover.is_empty() { + raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); + } + // Queue after injecting, so a `ui.dropFiles` request is consumed exactly once by the UI phase. + self.queue_dropped_files(raw); + // Track the real host modifier state separately from the app's synthetic ownership. A host + // change means the host now owns the modifier state: relinquish any synthetic claim and do + // not later synthesise a release for a key that is physically held. + if let Some(host_mods) = raw.events.iter().rev().find_map(|event| match event { + egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), + _ => None, + }) { + self.host_mods = host_mods; + self.synthetic_mods = egui::Modifiers::NONE; + self.synthetic_mods_release = false; + } + // A prompt owns all input. On its first host frame this also removes pointer/touch input + // before egui computes its hit test; later frames keep only key releases, the prompt's own + // keys, and AccessKit actions targeted at a prompt button. See `notices::filter_raw_input`. + let batch_in_flight = self.synthetic_in_flight; + if crate::panels::notices::prepare_raw_input(self, ctx, raw) { + // A prompt appearing during a synthetic drag must abort the drag, not let its remaining + // move/release events act under the prompt or after it is dismissed. If a whole batch + // was already injected, it is unsafe as well: this frame's prompt filter drops it + // before a panel sees it, so it must not report a successful delivery. + self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); + self.queue_pointer_cleanup(); + } + // Enforce the stall deadline before injecting another event: a request that expired during + // a long frame gap must not get one more event on the frame that resumes. + self.expire_gestures(); + // A canceled press is released by resetting egui's pointer state before its next input + // pass, even while another batch is in flight or the fonts are not ready, so the pointer + // cannot stay down. No move/release event is injected: either could start a drag or + // become a click on the widget being canceled. + self.apply_pointer_cleanup(ctx, raw); + if !self.fonts_ready || self.synthetic_in_flight { + return; + } + if self.drop_gestures_stale_before_injection(ctx) { + // The stale check can cancel the head gesture while its press is already down. + self.apply_pointer_cleanup(ctx, raw); + return; + } + // the frame after a synthetic release / key: restore the host's physical modifiers, not + // necessarily NONE (Shift may still be held while a synthetic Alt gesture is released) + if std::mem::take(&mut self.synthetic_mods_release) && self.synthetic_mods != egui::Modifiers::NONE { + self.synthetic_mods = egui::Modifiers::NONE; + raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); + } + if self.synthetic.is_empty() { + return; + } + let n = match self.synthetic[0] { + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + // A press needs the pointer at its position before the button event; the move that is + // itself the first event is delivered by the drain below, so it must not be sent twice. + if let Some(egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + // egui's `input.modifiers` follow `ModifiersChanged` events, not the modifiers carried by + // pointer/key events: announce the injected events' modifiers (held from button down to + // the frame after up), so ⌥-drag, ⇧-click … work through the control channel + let carried = self.synthetic[..n].iter().find_map(|e| match e { + egui::Event::PointerButton { pressed, modifiers, .. } => Some((*modifiers, !pressed)), + egui::Event::Key { modifiers, .. } => Some((*modifiers, true)), + _ => None, + }); + if let Some((m, ends)) = carried { + if m != self.synthetic_mods { + self.synthetic_mods = m; + raw.events.push(egui::Event::ModifiersChanged(m)); + } + self.synthetic_mods_release = ends; + } + let blocked_by_prompt = self.synthetic[..n].iter().any(|event| !crate::panels::notices::prompt_allows_event(self, event)); + let head = self.head_gesture(); + if let Some(head) = head { + let gesture = &mut self.pending_gestures[head]; + for event in &self.synthetic[..n] { + match event { + egui::Event::PointerButton { button, pressed: true, .. } => { + gesture.pressed = Some(InjectedPress { button: *button }); + } + egui::Event::PointerButton { button, pressed: false, .. } + if gesture.pressed.as_ref().is_some_and(|press| press.button == *button) => + { + gesture.pressed = None; + } + _ => {} + } + } + gesture.started = true; + gesture.remaining_events = gesture.remaining_events.saturating_sub(n); + } + raw.events.extend(self.synthetic.drain(..n)); + // The same barrier applies after injection: only the prompt's own keys and targeted + // AccessKit actions survive; host input and `ui.text`/clipboard never reach the background. + crate::panels::notices::filter_raw_input(self, raw); + if blocked_by_prompt && let Some(index) = head { + // A settled prompt is already laid out, so this is not the prompt-creation-cancels-all + // path: only the gesture whose batch the barrier removed fails. Allowed prompt keys, + // pointer input and targeted AccessKit actions still report a successful delivery. + let gesture = self.pending_gestures.remove(index); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the open modal prompt blocked this input", gesture.method), + })); + } + // The 5 s deadline is a delivery-stall limit, not a limit on a gesture's frame count: a + // drag feeds one event per host frame, so every batch that the app consumes proves the UI + // is alive and restarts the clock for the whole queue. + let now = self.tick_now_ms(); + for gesture in &mut self.pending_gestures { + gesture.deadline_ms = now + GESTURE_TIMEOUT_MS; + } + self.synthetic_in_flight = true; + } +} + +#[cfg(test)] +mod drop_tests { + #[test] + 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); + assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); + assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); + } + + #[test] + 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"); + } + + #[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}"); + } + for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { + assert!(!super::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}"); + } + for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { + assert!(!super::is_preset_file(p), "{p}"); + } + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs new file mode 100644 index 000000000..a85a5f751 --- /dev/null +++ b/crates/ui-egui/src/app/prefs.rs @@ -0,0 +1,114 @@ +use super::*; + +impl LightcraftApp { + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + + /// Back from an external editor: pick up the files it saved. Reads `i.focused`, which + /// `run_logic` does not refresh, so it belongs to the UI phase. + pub(super) fn check_external_editor(&mut self, ctx: &egui::Context) { + let focused = ctx.input(|i| i.focused); + if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() { + let ids = self.ui.external_edits.clone(); + if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) + && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) + { + self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); + } + } + self.ui.was_focused = focused; + } + + /// Apply settings that act outside the UI state: GPU rendering and window full screen. + pub(super) fn apply_settings(&mut self, ctx: &egui::Context) { + if self.gpu_applied != Some(self.ui.settings.gpu) { + self.gpu_applied = Some(self.ui.settings.gpu); + let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + // GPU device + kernels off the UI thread, once the window is up and only when GPU + // rendering is on: a broken driver must not keep the window from appearing (issue #136) + if self.ui.settings.gpu { + lightcraft_engine::gpu::warm_up(); + } + } + let mb = self.ui.settings.memory_mb; + // automatic at startup: leave the engine's default alone + if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { + let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; + let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); + } + self.memory_applied = Some(mb); + // the monitor profile (Settings ▸ Display): previews follow it from the next frame + if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) { + let path = self.ui.settings.display_profile.clone(); + match lightcraft_engine::display::Display::load_opt(&path) { + Ok(d) => { + self.renderer.set_display(d); + self.display_error = None; + } + Err(msg) => { + // a profile that can't be used: previews are sRGB, the setting is kept + self.renderer.set_display(None); + log::warn!("display profile: {msg}"); + self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used"))); + self.display_error = Some(msg); + } + } + self.display_applied = Some(path); + // none chosen again: the screen's profile is read afresh + self.screen_profile = None; + self.screen_profile_read = None; + } + if self.ui.settings.display_profile.is_empty() { + self.follow_screen_profile(ctx); + } + if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { + self.window_is_fullscreen = fs; + } + if let Some(on) = self.ui.window_fullscreen.take() { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); + self.window_is_fullscreen = on; + } + } + + /// While no display profile is chosen, show previews through the profile of the screen the + /// window is on (`services.screen_profile`), read about once a second so moving the window to + /// another screen is followed. Without the service (or a profile) the display is sRGB. + fn follow_screen_profile(&mut self, _ctx: &egui::Context) { + let now = self.tick_now(); + if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { + return; + } + self.screen_profile_read = Some(now); + self.apply_screen_profile(); + } + + /// Read the screen's profile now and show previews through it (sRGB without one). + pub(crate) fn apply_screen_profile(&mut self) { + let Some(read) = self.services.screen_profile.as_mut() else { return }; + let icc = read(); + if icc == self.screen_profile { + return; + } + let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { + Ok(d) => Some(std::sync::Arc::new(d)), + Err(msg) => { + // a profile we can't use: previews stay sRGB, as before #787 + log::warn!("screen profile: {msg}"); + None + } + }); + self.renderer.set_display(display); + self.screen_profile = icc; + } +} diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs new file mode 100644 index 000000000..c2de3aae6 --- /dev/null +++ b/crates/ui-egui/src/app/screenshot.rs @@ -0,0 +1,357 @@ +use super::*; +use crate::control; + +impl LightcraftApp { + /// The immutable identity a capture is bound to: the library generation. It changes when a + /// different library is opened, not on every edit, import batch or keyword op (`catalog.revision` + /// bumps on all of those), so a screenshot during interactive editing still belongs to the + /// document the user asked for. + pub(crate) fn shot_document_generation(&self) -> u64 { + self.session.library_generation() + } + + /// The window's current surface geometry as native points plus native pixels per point. + /// `run_logic` refreshes `input.viewports`, so this is fresh even while the UI is not laid out; + /// without one, the last laid-out content rect is used. + fn shot_geometry(&self, ctx: &egui::Context) -> ShotGeometry { + // Both `viewport.inner_rect` and the content rect are in egui points, and `pixels_per_point` + // already includes the UI zoom (egui-winit divides by the zoomed ppp). Multiplying by the + // zoom again would reserve a surface that is too small, so the zoom is only tracked as a + // change signal. + let zoom = ctx.zoom_factor(); + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + let pixels_per_point = ctx.input(|i| i.pixels_per_point()); + let viewport = ctx.input(|i| { + i.viewport().inner_rect.filter(|r| r.width() > 0.0 && r.height() > 0.0).map_or_else(|| i.content_rect().size(), |inner| inner.size()) + }); + ShotGeometry { viewport, pixels_per_point, zoom, epoch: self.shot_geometry_epoch } + } + + /// Track geometry changes as a generation. Called once per logic tick from the refreshed + /// viewport information, so a hidden window still notices a resize. + pub(super) fn note_shot_geometry(&mut self, ctx: &egui::Context) { + let mut now = self.shot_geometry(ctx); + if let Some(last) = self.last_shot_geometry + && (last.viewport != now.viewport || last.pixels_per_point != now.pixels_per_point || last.zoom != now.zoom) + { + self.shot_geometry_epoch = self.shot_geometry_epoch.wrapping_add(1); + now.epoch = self.shot_geometry_epoch; + } + self.last_shot_geometry = Some(now); + } + + /// The texture limit the host reported (16 384 unless it said otherwise). + fn max_texture_side(&self) -> usize { + self.max_texture_side + } + + /// Retain the host's `RawInput::max_texture_side`. Hosts (egui-winit, `Headless`) report it + /// only when it changes, so keep the last value instead of assuming 16 384. + pub(super) fn note_max_texture_side(&mut self, ctx: &egui::Context) { + if let Some(limit) = ctx.input(|i| i.raw.max_texture_side) + && limit > 0 + { + self.max_texture_side = limit; + } + } + + /// Reserve a new image's `bytes` against the aggregate screenshot budget, or report why not. + fn reserve_shot_bytes(&mut self, bytes: usize) -> Result<(), String> { + if bytes > MAX_SHOT_BYTES { + return Err(format!("ui.screenshot: {bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let total = self.shot_bytes.checked_add(bytes).ok_or("ui.screenshot: byte reservation overflow")?; + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); + } + self.shot_bytes = total; + Ok(()) + } + + /// A surface that grew after admission (a stale `content_rect` on Wayland, a resize between + /// issue and completion): bound the *new image* against the per-image limit, then reserve only + /// the growth against the aggregate. The old per-image check would compare the delta to the + /// per-image cap and name the delta as if it were the image. + fn grow_shot_reservation(&mut self, current_reserved: usize, image_bytes: usize) -> Result<(), String> { + if image_bytes > MAX_SHOT_BYTES { + return Err(format!("ui.screenshot: {image_bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let extra = image_bytes.saturating_sub(current_reserved); + let total = self.shot_bytes.checked_add(extra).ok_or("ui.screenshot: byte reservation overflow")?; + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); + } + self.shot_bytes = total; + Ok(()) + } + + /// Release a reservation; never let an accounting bug underflow `usize`. + fn release_shot_bytes(&mut self, bytes: usize) { + self.shot_bytes = self.shot_bytes.saturating_sub(bytes); + } + + /// Admit a `ui.screenshot` request against the bounded capture budget. Nothing is written and + /// no capture is issued here: [`Self::issue_screenshots`] waits for a settled, nondiscarded UI + /// pass and a fresh geometry generation. + pub(crate) fn admit_shot( + &mut self, + ctx: &egui::Context, + target: Option, + headless: bool, + reply: Sender, + ) -> Result<(), String> { + if self.pending_screenshots.len() >= MAX_PENDING_SHOTS { + return Err(format!("ui.screenshot: {MAX_PENDING_SHOTS} requests are already pending; wait for a reply first")); + } + let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); + let bytes = geometry.bytes(self.max_texture_side()).ok_or_else(|| geometry.rejection(self.max_texture_side()))?; + self.reserve_shot_bytes(bytes)?; + self.screenshot_token = self.screenshot_token.wrapping_add(1); + let now = self.tick_now_ms(); + self.pending_screenshots.push(PendingShot { + token: self.screenshot_token, + target, + reply, + headless: headless && !self.headless_host, + admitted_passes: self.ui_passes, + not_before_ms: now + SHOT_SETTLE_MS, + // Capture the current settled frame at this instant even if renders are still in flight + // (0.36 did this at its three-second deadline, so automation never got an error instead + // of an image while a slider/import kept the renderer busy). + force_ms: now + SHOT_FORCE_BUSY_MS, + // Settle + forced capture + compositor fallback + the headless settle, all from admission. + deadline_ms: now + SHOT_SETTLE_MS + SCREENSHOT_SETTLE_MS + SCREENSHOT_FALLBACK_MS + SCREENSHOT_SETTLE_MS, + sent_at_ms: None, + reserved: bytes, + slot: None, + issued: None, + issued_library: self.shot_document_generation(), + }); + ctx.request_repaint(); + Ok(()) + } + + /// Finish a rendered headless capture, growing its reservation before writing if needed. + /// Every path replies once and releases the request's reserved bytes. + pub(crate) fn finish_headless_shot(&mut self, shot: &mut PendingShot, img: &egui::ColorImage) { + let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + let result = match bytes { + Some(bytes) if bytes > shot.reserved => self.grow_shot_reservation(shot.reserved, bytes).map(|()| { + shot.reserved = bytes; + }), + Some(_) => Ok(()), + None => Err("ui.screenshot: the rendered image has invalid dimensions".to_owned()), + }; + let reply = match result { + Ok(()) => control::save_screenshot(self, img, shot.target.as_ref()), + Err(error) => serde_json::json!({"ok": false, "error": error}), + }; + let _ = shot.reply.send(reply); + self.release_shot_bytes(shot.reserved); + } + + /// Advance pending screenshots: wait (≥ 3 completed UI frames, ≥ 120 ms) until no renders are + /// in flight — or until the forced-capture instant, so sustained rendering cannot turn a + /// request into a timeout — then capture through the compositor's one-shot callback, or + /// headlessly ([`Self::headless_screenshot`]) when asked or when the compositor delivers nothing. + pub(crate) fn issue_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let now = self.tick_now_ms(); + let busy = self.renderer.in_flight() > 0 || self.merge.busy(); + let max_side = self.max_texture_side(); + let library = self.shot_document_generation(); + let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); + let mut shots = std::mem::take(&mut self.pending_screenshots); + let mut shadow_ticked = false; + let mut keep = Vec::with_capacity(shots.len()); + for mut shot in shots.drain(..) { + // Hard deadline from admission: a compositor fallback does not extend it. + if now > shot.deadline_ms { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + self.release_shot_bytes(shot.reserved); + continue; + } + if let Some(sent) = shot.sent_at_ms { + if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host { + keep.push(shot); + continue; + } + // The compositor delivered nothing (display asleep, window occluded): go headless. + log::warn!("ui.screenshot: no frame from the compositor after {SCREENSHOT_FALLBACK_MS} ms; rendering headlessly"); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + shot.sent_at_ms = None; + shot.issued = None; + shot.headless = true; + } + // A busy renderer only defers the capture until the forced instant; after that the + // settled frame that exists now is captured (0.36's deadline behavior), never an error. + let ready = shot_ready(now, &shot, busy, self.ui_passes); + if !ready { + keep.push(shot); + continue; + } + if shot.headless { + // The shadow frame requests the renders the UI needs, even while the window shows nothing. + let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) }; + shadow_ticked = true; + if let Some(img) = img { + self.finish_headless_shot(&mut shot, &img); + continue; + } + keep.push(shot); + continue; + } + // The compositor path needs a real, laid-out UI and fresh fonts; a queued request on a + // hidden window is kept alive by RequestPaintWhileHidden from `logic`. + if !self.fonts_ready || self.ui_passes == 0 { + keep.push(shot); + continue; + } + // Revalidate the reservation against the geometry the command is actually bound to. + let Some(bytes) = geometry.bytes(max_side) else { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": geometry.rejection(max_side)})); + self.release_shot_bytes(shot.reserved); + continue; + }; + if bytes > shot.reserved { + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + // One slot and one callback per issue; the callback only holds a weak slot and wakes + // the viewport. File writes and replies happen in `collect_screenshots`. + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + log::debug!("ui.screenshot {}: issued at {:?}, {} bytes reserved", shot.token, geometry.viewport, shot.reserved); + shot.slot = Some(slot); + shot.sent_at_ms = Some(now); + shot.issued = Some(geometry); + shot.issued_library = library; + keep.push(shot); + } + self.pending_screenshots = keep; + if !self.pending_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it + /// on the CPU (photo textures come from the renderer's CPU copies). + pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { + let mut view = self.shadow.take().unwrap_or_default(); + let size = main.input(|i| i.content_rect()).size(); + let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) }; + // Feed native viewport points; the shadow context applies the copied UI zoom. + let zoom = main.zoom_factor(); + let size = size * zoom; + let ppp = main.pixels_per_point() / zoom; + let time = main.input(|i| i.time); + if view.frames() == 0 { + // warm-up pass: activates our fonts (pending font definitions live in `Memory`, which + // is replaced below) + view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |_| {}); + } + // same scroll offsets, open sections, style… as the window + let memory = main.memory(|m| m.clone()); + view.ctx.memory_mut(|m| *m = memory); + view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.draw_window(ui)); + let img = capture.then(|| { + let mut tex = self.renderer.cpu_textures(); + if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { + tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); + } + view.paint(&tex) + }); + self.shadow = Some(view); + img + } + + /// Take each completed capture once, reject stale generations before writing anything, and + /// reply exactly once. A late callback after cancel/drop finds no slot and is inert. + pub(crate) fn collect_screenshots(&mut self, _ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let now = self.tick_now_ms(); + let current = self.last_shot_geometry; + let library = self.shot_document_generation(); + let mut shots = std::mem::take(&mut self.pending_screenshots); + let mut keep = Vec::with_capacity(shots.len()); + for mut shot in shots.drain(..) { + let completed = shot.slot.as_ref().and_then(craft_ui::capture::CaptureSlot::take); + match completed { + Some(Ok(image)) => { + let stale = + shot.issued.is_none_or(|issued| current.is_none_or(|now| now.epoch != issued.epoch)) || shot.issued_library != library; + if stale { + // A resize/DPI/zoom or library change between issue and completion: never + // combine old pixels with new geometry. Retry with a fresh slot. + shot.slot = None; + shot.sent_at_ms = None; + shot.issued = None; + shot.not_before_ms = now + SHOT_SETTLE_MS; + keep.push(shot); + continue; + } + let pixels = image.size[0].checked_mul(image.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + let Some(bytes) = pixels else { + let reply = serde_json::json!({ + "ok": false, + "error": "ui.screenshot: the rendered image has invalid dimensions", + }); + let _ = shot.reply.send(reply); + self.release_shot_bytes(shot.reserved); + continue; + }; + if bytes > shot.reserved { + // Wayland reports no `inner_rect`, so the reservation can be based on a stale + // content rect while the compositor returns the new, larger surface. Treat a + // larger-but-legal image as a stale reservation and grow it; never mix it + // with geometry of another epoch (checked above). + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + self.release_shot_bytes(shot.reserved); + let _ = shot.reply.send(control::save_screenshot(self, &image, shot.target.as_ref())); + } + Some(Err(error)) => { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error.to_string()})); + self.release_shot_bytes(shot.reserved); + } + None => { + if now > shot.deadline_ms { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + self.release_shot_bytes(shot.reserved); + } else { + keep.push(shot); + } + } + } + } + self.pending_screenshots = keep; + } +} + +/// Whether a pending request may be issued now: it has waited for the settle time and completed +/// UI frames, and either no render is in flight or the forced-capture instant has passed. +pub(crate) fn shot_ready(now_ms: f64, shot: &PendingShot, busy: bool, ui_passes: u64) -> bool { + let settled = ui_passes.saturating_sub(shot.admitted_passes) >= SHOT_SETTLE_FRAMES && now_ms >= shot.not_before_ms; + settled && (!busy || now_ms >= shot.force_ms) +} diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs new file mode 100644 index 000000000..0fb5a4b2c --- /dev/null +++ b/crates/ui-egui/src/caches.rs @@ -0,0 +1,469 @@ +use super::*; + +/// The left panel's counts. +#[derive(Clone, Copy, Debug, Default)] +pub struct LibraryCounts { + pub total: usize, + pub picks: usize, + pub deleted: usize, +} + +/// The values the filter bar's pickers offer. +#[derive(Clone, Debug, Default)] +pub struct FilterValues { + pub cameras: Vec, + pub lenses: Vec, + pub keywords: Vec, +} + +/// What the smart-album rule dialog shows besides the rules themselves: their problems (each row +/// marks its own), how many photos they match and the albums an Album rule picks from. +pub struct RulesView { + pub problems: Vec, + pub count: usize, + pub albums: Vec, +} + +pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { + use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher}; + BuildHasherDefault::::default().hash_one(parts) +} + +/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only +/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's +/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the +/// library saved on every edit. +fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool { + revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads)) +} + +/// What the tallies below read of a photo, besides whether it is in the library. +const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE); + +const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META); + +const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS); + +const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED); + +const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG); + +impl Caches { + /// The folders the library's photos were imported from. + pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree())); + self.folder_tree = Some((cat.revision, t.clone())); + t + } + /// The folder tree as last built, when it still holds (a read-only check cannot rebuild it). + pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option>> { + self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone()) + } + /// The library's keyword tree. + pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree())); + self.keyword_tree = Some((cat.revision, t.clone())); + t + } + /// The Keywording box's read-only names for the selection: what exported files carry + /// (`export`), or the keywords with those containing them. + pub(crate) fn keyword_names( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + export: bool, + ) -> std::sync::Arc> { + let key = key_of((selection, export)); + match &self.keyword_names { + Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + _ => { + let c = std::sync::Arc::new(if export { + panels::keywording::will_export(cat, selection) + } else { + panels::keywording::with_containing(cat, selection) + }); + self.keyword_names = Some((cat.revision, key, c.clone())); + c + } + } + } + /// The selection's keywords (the Keywording box's chips). + pub(crate) fn keyword_chips( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc> { + let key = key_of(selection); + match &self.keyword_chips { + Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + _ => { + let c = std::sync::Arc::new(panels::keywording::chips(cat, selection)); + self.keyword_chips = Some((cat.revision, key, c.clone())); + c + } + } + } + /// How many photos the selection is: each once, those the library has (what the Keywording + /// box's "N of M" counts). + pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize { + let key = key_of(selection); + match self.selected_photos { + Some((r, k, n)) if r == cat.revision && k == key => n, + _ => { + let n = panels::keywording::distinct(cat, selection).len(); + self.selected_photos = Some((cat.revision, key, n)); + n + } + } + } + /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). + pub(crate) fn keyword_groups( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc> { + let key = key_of(selection); + match &self.keyword_groups { + Some((r, k, g)) if *r == cat.revision && *k == key => g.clone(), + _ => { + let chips = self.keyword_chips(cat, selection); + let g = std::sync::Arc::new(panels::keywording::grouped(&chips)); + self.keyword_groups = Some((cat.revision, key, g.clone())); + g + } + } + } + /// How many selected photos have each keyword (the Keyword List's tick boxes). + pub(crate) fn keyword_ticks( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc { + let key = key_of(selection); + match &self.keyword_ticks { + Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(), + _ => { + let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection)); + self.keyword_ticks = Some((cat.revision, key, t.clone())); + t + } + } + } + /// The people named on faces among the photos the filter lets through (its own `person` aside), + /// with photo counts. + pub fn people( + &mut self, + cat: &lightcraft_catalog::Catalog, + filter: &lightcraft_catalog::Filter, + ) -> std::sync::Arc> { + let key = lightcraft_catalog::Filter { person: None, ..filter.clone() }; + match &self.people { + Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), + _ => { + let t = std::sync::Arc::new(cat.people_in(&key)); + self.people = Some((cat.revision, key, t.clone())); + t + } + } + } + /// All Photos / Picks / Recently Deleted counts. + pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { + if let Some((r, c)) = self.counts + && still_right(cat, r, READS_FLAG) + { + self.counts = Some((cat.revision, c)); + return c; + } + let mut c = LibraryCounts::default(); + for p in cat.photos() { + if p.in_library() { + c.total += 1; + if p.flag == lightcraft_catalog::Flag::Pick { + c.picks += 1; + } + } else if p.deleted && !p.local { + c.deleted += 1; + } + } + self.counts = Some((cat.revision, c)); + c + } + /// Everyone named on a face in the library (for completing a name as it is typed). + pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v); + let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::>())); + self.person_names = Some((cat.revision, v.clone())); + v + } + /// The By Date tree. + pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g); + let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups())); + self.date_groups = Some((cat.revision, g.clone())); + g + } + /// Cameras, lenses and keywords in the library (filter bar pickers). + pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc { + match &self.filter_values { + Some((r, v)) if *r == cat.revision => v.clone(), + _ => { + let distinct = |mut v: Vec| { + v.retain(|s| !s.trim().is_empty()); + v.sort_by_key(|s| s.to_lowercase()); + v.dedup(); + v + }; + let lib = || cat.photos().filter(|p| p.in_library()); + let v = std::sync::Arc::new(FilterValues { + cameras: distinct(lib().map(|p| p.meta.camera.clone()).collect()), + lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()), + keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(), + }); + self.filter_values = Some((cat.revision, v.clone())); + v + } + } + } + /// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart + /// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks + /// through the library's keywords. + pub(crate) fn completions( + &mut self, + cat: &lightcraft_catalog::Catalog, + current: &[String], + typed: &str, + ) -> std::sync::Arc { + let k = key_of((cat.revision, current, typed)); + match &self.completions { + Some((h, v)) if *h == k => v.clone(), + _ => { + let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed)); + self.completions = Some((k, v.clone())); + v + } + } + } + /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. + pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc> { + let k = key_of((cat.revision, current, prefix, n)); + match &self.suggestions { + Some((h, v)) if *h == k => v.clone(), + _ => { + let v = std::sync::Arc::new(cat.keyword_suggestions(current, prefix, n)); + self.suggestions = Some((k, v.clone())); + v + } + } + } + /// Every album's photo count for the sidebar (a smart album evaluates its rules, which + /// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules + /// all bump its revision) and — only while some smart album has an "in the last…" rule — + /// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock + /// within a minute without rescanning every frame. + /// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within + /// `folder` when it was made from a folder view: worked out again only when the catalog, the + /// rules, the language or (for "in the last…" rules) the minute changes, not every frame. + pub fn rules_view( + &mut self, + cat: &lightcraft_catalog::Catalog, + rules: &lightcraft_catalog::RuleSet, + editing: Option, + folder: Option, + now: &str, + ) -> std::sync::Arc { + let relative = rules.depends_on_now(); + let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; + let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code())); + if let Some((key, v)) = &self.rules_view + && *key == k + { + return v.clone(); + } + if relative { + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let problems = rules.check_for(cat, editing.map(lightcraft_catalog::AlbumId)); + let filter = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; + let count = if problems.is_empty() { cat.query(&filter, &Default::default()).len() } else { 0 }; + let v = std::sync::Arc::new(RulesView { problems, count, albums: panels::dialogs::album_entries(cat, editing) }); + self.rules_view_scans += 1; + self.rules_view = Some((k, v.clone())); + v + } + + /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs + /// again only when the catalog or `what` (what is counted) changes, not every frame. + pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { + let k = key_of((cat.revision, what)); + if let Some((key, n)) = self.dialog_count + && key == k + { + return n; + } + let n = count(); + self.dialog_count_scans += 1; + self.dialog_count = Some((k, n)); + n + } + + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), + /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. + pub fn smart_album_problems( + &mut self, + cat: &lightcraft_catalog::Catalog, + ) -> std::sync::Arc>> { + if let Some((rev, v)) = &self.smart_problems + && *rev == cat.revision + { + return v.clone(); + } + let v: std::sync::Arc> = std::sync::Arc::new( + cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(), + ); + self.smart_problem_scans += 1; + self.smart_problems = Some((cat.revision, v.clone())); + v + } + + pub fn album_counts( + &mut self, + cat: &lightcraft_catalog::Catalog, + now: &str, + ) -> std::sync::Arc> { + let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); + let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; + // the counts of this minute, if there are any: an edit to a photo or a few is followed from them, a smart + // album asking only those photos again; anything more, or a new minute, is counted from every photo + let kept = self.album_counts.take().filter(|(of, _, _)| of == minute); + if let Some((_, tally, shown)) = &kept + && tally.revision() == cat.revision + { + let shown = shown.clone(); + self.album_counts = kept; + return shown; + } + if relative { + // "in the last N days" counts back from the session clock, as in the grid. (Within a minute an edit asks + // only the photos it touched, at this second; the others were asked earlier in the minute. That is as + // far out as the counts already were between edits, and the next minute counts them all again.) + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let tally = match kept { + Some((_, mut tally, _)) => { + if !tally.update(cat) { + self.album_count_scans += 1; + } + tally + } + None => { + self.album_count_scans += 1; + lightcraft_catalog::tally::AlbumTally::of(cat) + } + }; + let shown = std::sync::Arc::new(tally.counts().clone()); + self.album_counts = Some((minute.to_string(), tally, shown.clone())); + shown + } +} + +#[cfg(test)] +mod cache_tests { + use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source}; + + fn photo(id: u64, captured: &str, rating: u8) -> Op { + let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some(captured.into()); + p.rating = rating; + Op::AddPhoto { photo: Box::new(p) } + } + + fn smart(id: u64, rules: serde_json::Value) -> Op { + let mut a = Album::new(AlbumId(id), format!("smart {id}")); + let rules: RuleSet = serde_json::from_value(rules).unwrap(); + a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() })); + Op::AddAlbum { album: a } + } + + /// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for + /// "in the last…" rules) the clock crossing into a new minute recompute them. + #[test] + fn smart_album_counts_are_cached_until_something_changes() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() { + cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap(); + } + cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); + cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap(); + let now = "2026-09-30T12:00:00"; + let n = c.album_counts(&cat, now); + assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1)); + assert_eq!(c.album_count_scans, 1); + for _ in 0..10 { + c.album_counts(&cat, "2026-09-30T12:00:40"); + } + assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan"); + // a photo rated: followed from that photo, not counted from every one + cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2); + assert_eq!(c.album_count_scans, 1); + // rule change + let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap(); + cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap(); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1); + // an hour later the 11:59:30 photo has left "in the last hour", with no catalog change + assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0); + assert_eq!(c.album_count_scans, 3, "new rules and a new minute are counted from every photo; the rating was not"); + lightcraft_catalog::rules::set_now(None); + } + + /// The rule dialog's problems, live count and album list are worked out once per change to the + /// catalog or the rules, not every frame; so are the sidebar's smart-album problems. + #[test] + fn rule_dialog_and_problems_are_cached_until_something_changes() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + for i in 0..3u64 { + cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap(); + } + let rules = + |v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() }; + let now = "2026-09-30T12:00:00"; + let view = c.rules_view(&cat, &rules(3), None, None, now); + assert_eq!((view.count, view.problems.len()), (1, 0)); + for _ in 0..10 { + c.rules_view(&cat, &rules(3), None, None, now); + } + assert_eq!(c.rules_view_scans, 1, "unchanged frames reuse it"); + assert_eq!(c.rules_view(&cat, &rules(1), None, None, now).count, 3, "a rule change"); + assert_eq!(c.rules_view(&cat, &rules(9), None, None, now).problems.len(), 1); + cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); + assert_eq!(c.rules_view(&cat, &rules(3), None, None, now).count, 2, "a catalog change"); + assert_eq!(c.rules_view_scans, 4); + // the sidebar's problems + cat.apply(Op::AddAlbum { album: lightcraft_catalog::Album::new(AlbumId(20), "Trip") }).unwrap(); + cat.apply(smart(21, serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 20}]}))).unwrap(); + assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_none_or(Vec::is_empty)); + for _ in 0..10 { + c.smart_album_problems(&cat); + } + assert_eq!(c.smart_problem_scans, 1); + cat.apply(Op::RemoveAlbum { id: AlbumId(20) }).unwrap(); + assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_some_and(|p| !p.is_empty()), "its album is gone"); + assert_eq!(c.smart_problem_scans, 2); + } + + /// Without "in the last…" rules the clock never causes a rescan. + #[test] + fn absolute_rules_ignore_the_clock() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap(); + cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); + c.album_counts(&cat, "2026-09-30T12:00:00"); + c.album_counts(&cat, "2027-01-01T00:00:00"); + assert_eq!(c.album_count_scans, 1); + } +} diff --git a/crates/ui-egui/src/canvas_keys.rs b/crates/ui-egui/src/canvas_keys.rs index dadbb1577..d0c9ee3ad 100644 --- a/crates/ui-egui/src/canvas_keys.rs +++ b/crates/ui-egui/src/canvas_keys.rs @@ -12,12 +12,12 @@ pub const STEP: f64 = 0.005; pub const BIG_STEP: f64 = 0.05; fn focused_key() -> Id { - Id::new("lc-canvas-handle-focused") + Id::unique("lc-canvas-handle-focused") } /// The egui id of the stand-in registered as `key` (to focus it, or find it in tests). pub fn id(key: &str) -> Id { - Id::new("lc-canvas-handle").with(key) + Id::unique("lc-canvas-handle").with(key) } /// Whether a canvas handle holds the keyboard focus: it keeps the arrow keys and Return from the @@ -49,11 +49,11 @@ pub fn handle(ui: &mut Ui, key: &str, name: &str, value: String, at: Pos2) -> Ha let rect = Rect::from_center_size(at, vec2(14.0, 14.0)); let resp = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); let label = name.to_string(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.clone())); ui.ctx().accesskit_node_builder(id, |node| { node.set_value(value); }); - crate::widgets::register(ui.ctx(), format!("canvasHandle:{key}"), rect); + crate::widgets::register(ui, format!("canvasHandle:{key}"), rect); let mut out = HandleKeys::default(); if !resp.has_focus() { return out; diff --git a/crates/ui-egui/src/clock.rs b/crates/ui-egui/src/clock.rs new file mode 100644 index 000000000..5650b0c44 --- /dev/null +++ b/crates/ui-egui/src/clock.rs @@ -0,0 +1,35 @@ +/// A monotonic clock for the app's own timers. +/// +/// `web_time::Instant` is backed by the browser's `performance.now()` on WASM and by +/// `std::time::Instant` natively: it never jumps with the wall clock and has no `Date.now()` +/// zero-origin problem. A headless host may supply its simulated time instead. +#[derive(Clone, Copy, Debug)] +pub(crate) struct AppClock { + start: web_time::Instant, + pub(crate) simulated: Option, +} + +impl AppClock { + /// Start the clock at the current monotonic instant with no simulated time. + pub(crate) fn new() -> Self { + Self { start: web_time::Instant::now(), simulated: None } + } + + /// Seconds on the clock: the supplied simulated time when set, otherwise time since `new`. + pub(crate) fn now(&self) -> f64 { + self.simulated.unwrap_or_else(|| self.start.elapsed().as_secs_f64()) + } +} + +/// Monotonic milliseconds since the first call, for durations and deadlines. +pub fn monotonic_ms() -> f64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 +} + +/// Wall-clock milliseconds since the Unix epoch, only for names that must be unique in the world +/// (`library-restored-`), never for timing or deadlines. +pub fn now_ms() -> f64 { + use web_time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7e04f1356..caacbc590 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -10,7 +10,7 @@ //! - `ui.move/click/drag {x, y, …}`: raw pointer input in screen points //! - `ui.pointer {events: [{kind: down|drag|up, x, y}], space: "image"}`: gestures in normalized //! image coordinates (brush strokes, crop handles…) — mapped to screen and injected as real input -//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}` / `ui.scroll {dx, dy}` +//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}` / `ui.scroll {dx, dy, source?, unit?, phase?}` //! - `ui.set {view?, panel?, filmstrip?, leftPanel?, zoom?, …}` / `ui.dialog.confirm|cancel` //! - `ui.screenshot {path?, headless?}` (headless: CPU-rendered, no compositor needed), `ui.resize {width, height}`, `ui.render {id?, size?, path?}`, `app.quit` @@ -59,7 +59,17 @@ impl ControlRequest { pub enum Outcome { Done(Value), - Screenshot { path: Option, headless: bool }, + /// A capture was admitted: `target` pins the destination identity until the write. + Screenshot { + target: Option, + headless: bool, + }, + /// A synthetic-input request that must wait for a fresh layout and for its last event to be + /// consumed before it is answered (see `LightcraftApp::admit_gesture`). + Gesture { + method: String, + params: Value, + }, } fn ok(v: Value) -> Outcome { @@ -160,6 +170,9 @@ fn modifiers(p: &Value) -> egui::Modifiers { egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } } +/// Parse a control-protocol key name into an egui key. `egui::Key::from_name` is tried first; +/// then lowercase aliases cover `return`/`enter`, `esc`/`escape`, `delete`/`backspace` (both +/// Backspace), arrow names, space/tab, and backslash, slash and square brackets. fn key_from(name: &str) -> Option { egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { "enter" | "return" => Some(egui::Key::Enter), @@ -179,6 +192,50 @@ fn key_from(name: &str) -> Option { }) } +/// Most clicks one `ui.click`/`ui.clickWidget` request may inject. The control server is a +/// local automation surface, but a request must not be able to fill memory with synthetic events. +pub(crate) const MAX_CLICKS: u64 = 16; +/// Most pointer-move steps one `ui.drag`/`ui.dragWidget` request may inject. +pub(crate) const MAX_DRAG_STEPS: u64 = 1000; + +/// What a widget gesture's synthetic events were aimed at when they were resolved. Delivery +/// rechecks this against the latest completed layout before the first event and again before every +/// press: a moved widget cancels the request instead of being retargeted. +#[derive(Clone, Debug)] +pub(crate) struct GestureTarget { + pub id: String, + /// The rect the request resolved against. An explicit `fx`/`fy` may place `at` outside it; + /// delivery accepts that only while egui's actual clipped interaction rectangle still + /// contains the point. + pub rect: egui::Rect, + /// The point the pointer events aim at. + pub at: egui::Pos2, + /// Whether the release must still find this widget. egui attributes a click on release while a + /// drag may legitimately end outside the widget it started on, so only `ui.clickWidget` sets + /// this. + pub check_release: bool, + /// Whether the first event must find the widget visible and topmost. A hover is not harmless + /// when its point is clipped out of the widget or covered by a non-modal overlay: the + /// documented contract refuses it rather than moving the pointer at a stale place. + pub check_visible: bool, +} + +/// The events queued for a resolved gesture, plus the widget target whose geometry must still hold +/// when the first event and each press are injected. +#[derive(Debug)] +pub(crate) struct GestureOutcome { + pub result: Value, + pub target: Option, +} + +impl GestureOutcome { + /// A gesture outcome that injected no widget-targeted events, so delivery has no geometry to + /// re-check. + fn done(result: Value) -> Self { + Self { result, target: None } + } +} + fn push_drag(app: &mut LightcraftApp, a: egui::Pos2, b: egui::Pos2, steps: u64, m: egui::Modifiers) { app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); @@ -189,10 +246,81 @@ fn push_drag(app: &mut LightcraftApp, a: egui::Pos2, b: egui::Pos2, steps: u64, app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); } -fn widget_rect(app: &LightcraftApp, id: &str) -> Option { +/// The rectangle registered for automation id `id` in the latest completed pass. The last +/// registration wins, so a redrawn widget's current geometry is returned. +pub(crate) fn widget_rect(app: &LightcraftApp, id: &str) -> Option { app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) } +/// The real egui widget that owns automation id `id` in the latest completed pass (the last +/// registration wins, as [`widget_rect`] does). +pub(crate) fn widget_hit(app: &LightcraftApp, id: &str) -> Option { + app.widget_hits.iter().rev().find(|(w, _)| w == id).map(|(_, hit)| *hit) +} + +/// Whether the registered automation rectangle still owns egui's pointer hit at `at`. +/// +/// A registration records the egui id and layer of a hover probe (or of the widget's own +/// response), plus that response's clipped `interact_rect` in the same completed pass. This asks +/// egui for the current areas at the point and checks: +/// +/// - the recorded clipped rectangle still contains the point (a widget scrolled out of its panel +/// has an empty interact rect even though its unclipped rect still contains the old point); +/// - the top-most interactable area at the point is the recorded layer; or +/// - the top-most area is the current modal layer and the recorded layer is below it. egui routes +/// every point below a modal to that modal, even outside the dialog window, so leaving the press +/// to egui is safe: the dialog consumes it or `clicked_backdrop` closes the dialog, and a +/// background widget cannot receive it. A non-modal overlay is not the modal layer, so it must +/// still match the target and is refused otherwise. The one exception is the explicitly +/// blocking library-problem/launch-list window: its dim also owns the modal layer, but a +/// background target is refused here rather than reported `ok:true` after the dim swallows the +/// press; only controls drawn in that window's own layer pass. Same-layer overlaps are invisible +/// to this area check; egui still delivers the press to whichever widget is top-most in that +/// layer. +/// +/// `interact_rect` comes from the latest completed registry, not `Context::read_response`: +/// `raw_input_hook` runs before `begin_pass`, where `read_response` checks `this_pass` before +/// `prev_pass` and can therefore return the pass before the one that just published the registry. +/// This matters when clipping, scrolling or a viewport change alters the target without changing +/// its layer. +/// +/// In pinned egui `6d518fc`, `Response::hovered` is deliberately false while any button is down +/// unless this is the clicked widget (`context.rs:1884-1886`, `interaction.rs:257-300`). A +/// hover-only probe cannot use it for a click release. `Response::contains_pointer` would survive +/// the press, but its hit-test snapshot is one frame behind the pointer move that precedes the +/// press (`context.rs:517-548` reads `prev_pass.widgets` at `begin_pass`). The recorded clipped rect +/// plus the current layer has no such lag. +/// +/// This also avoids the old centre-of-an-interactive-rect heuristic: a registration may be a +/// sub-rect of its widget's interactive response (rating stars, date cells, a checkbox's box, a +/// profile card's star), so the interactive rect's centre need not lie in the registered rect. +/// Disabled controls keep working as a no-op: their own probe is enabled and the injected click +/// is ignored by egui, which is the documented `ui.clickWidget` semantics. +pub(crate) fn widget_target_visible(ctx: &egui::Context, hit: Option, at: egui::Pos2) -> bool { + let Some(hit) = hit else { + // A hand-built registry entry (tests) or painted geometry has no egui widget to own a + // press. Production widget registrations always record a probe or a real response. + return false; + }; + let interact_rect = ctx.layer_transform_to_global(hit.layer_id).map_or(hit.interact_rect, |to_global| to_global * hit.interact_rect); + if !interact_rect.is_finite() || !interact_rect.contains(at) { + return false; + } + let (top_modal, allows_interaction, above_modal) = + ctx.memory(|memory| (memory.top_modal_layer(), memory.allows_interaction(hit.layer_id), memory.is_above_modal_layer(hit.layer_id))); + if crate::panels::library_problem::refuses_control_target(top_modal, hit.layer_id) { + return false; + } + let at_layer = ctx.layer_id_at(at); + if at_layer == Some(hit.layer_id) { + return allows_interaction; + } + top_modal == at_layer && !above_modal +} + +/// Dispatch one control request: run engine commands, list menus/widgets, inspect state, queue +/// file drops and synthetic input, capture screenshots, or return an error for an unknown or +/// invalid request. Requests that need fresh widget geometry are queued as `Outcome` values. pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -217,85 +345,6 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest app.widgets.iter().filter(|(id, _)| id.contains(filter)).map(|(id, r)| json!({"id": id, "rect": rect_json(*r)})).collect(), )) } - "ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" => { - let Some(id) = s("id") else { return err("missing `id`") }; - let Some(r) = widget_rect(app, id) else { return err(format!("no widget `{id}` on screen (see ui.widgets)")) }; - let m = modifiers(p); - // optional relative position inside the widget (0..1) - let at = egui::pos2(r.left() + r.width() * f("fx").unwrap_or(0.5) as f32, r.top() + r.height() * f("fy").unwrap_or(0.5) as f32); - if req.method == "ui.hoverWidget" { - app.synthetic.push(egui::Event::PointerMoved(at)); - } else if req.method == "ui.clickWidget" { - let n = p.get("count").and_then(Value::as_u64).unwrap_or(1); - let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; - app.synthetic.push(egui::Event::PointerMoved(at)); - for _ in 0..n { - app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: m }); - app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: m }); - } - } else { - let to = egui::pos2( - f("toX").map(|v| v as f32).unwrap_or(at.x + f("dx").unwrap_or(0.0) as f32), - f("toY").map(|v| v as f32).unwrap_or(at.y + f("dy").unwrap_or(0.0) as f32), - ); - push_drag(app, at, to, p.get("steps").and_then(Value::as_u64).unwrap_or(10), m); - } - ctx.request_repaint(); - ok(json!({"rect": rect_json(r)})) - } - "ui.move" => { - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32))); - ctx.request_repaint(); - ok(Value::Null) - } - "ui.click" | "ui.drag" => { - let a = egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32); - let m = modifiers(p); - let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; - if req.method == "ui.drag" { - let b = egui::pos2(f("toX").unwrap_or(0.0) as f32, f("toY").unwrap_or(0.0) as f32); - push_drag(app, a, b, p.get("steps").and_then(Value::as_u64).unwrap_or(10), m); - } else { - app.synthetic.push(egui::Event::PointerMoved(a)); - for _ in 0..p.get("count").and_then(Value::as_u64).unwrap_or(1) { - app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers: m }); - app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: false, modifiers: m }); - } - } - ctx.request_repaint(); - ok(Value::Null) - } - "ui.pointer" => { - // events in normalized image coordinates of the displayed photo frame - let Some(img) = app.image_rect else { return err("no image on screen (switch to Detail view)") }; - let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; - let m = modifiers(p); - for e in events { - let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.5) as f32; - let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.5) as f32; - let pos = egui::pos2(img.left() + x * img.width(), img.top() + y * img.height()); - match e.get("kind").and_then(Value::as_str).unwrap_or("move") { - "down" => { - app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); - } - "up" => { - app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: m }) - } - _ => app.synthetic.push(egui::Event::PointerMoved(pos)), - } - } - ctx.request_repaint(); - ok(json!({"events": events.len()})) - } - "ui.key" => { - let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; - let m = modifiers(p); - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); - ctx.request_repaint(); - ok(Value::Null) - } "ui.dropFiles" => { // every entry must be a string: a script is not told `ok` for a part of what it sent let Some(paths) = @@ -313,49 +362,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ctx.request_repaint(); ok(Value::Null) } - "ui.text" => { - app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); - ctx.request_repaint(); - ok(Value::Null) - } - // what the system does for ⌘X / ⌘C / ⌘V in a text field: paste `text`, or (without it) - // what the system clipboard holds - "ui.clipboard" => { - // outside a text field ⌘C / ⌘V are the app's shortcuts (and the macOS menu bar's): - // agents run those as commands (`develop.copy`, `develop.paste`) - if !ctx.text_edit_focused() { - return err("ui.clipboard: no text field has the focus (click one first)"); - } - match s("action") { - Some("cut") => app.synthetic.push(egui::Event::Cut), - Some("copy") => app.synthetic.push(egui::Event::Copy), - Some("paste") => match s("text") { - Some(text) => app.synthetic.push(egui::Event::Paste(text.to_string())), - None => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), - }, - _ => return err("ui.clipboard: `action` is cut, copy or paste"), - } - ctx.request_repaint(); - ok(Value::Null) - } - "ui.scroll" => { - app.synthetic.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(f("dx").unwrap_or(0.0) as f32, f("dy").unwrap_or(0.0) as f32), - phase: egui::TouchPhase::Move, - modifiers: modifiers(p), - }); - ctx.request_repaint(); - ok(Value::Null) - } - "ui.zoom" => { - let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else { - return err("ui.zoom: factor must be a finite positive number (1 = unchanged)"); - }; - app.synthetic.push(egui::Event::Zoom(factor)); - ctx.request_repaint(); - ok(Value::Null) + "ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" | "ui.move" | "ui.click" | "ui.drag" | "ui.pointer" | "ui.key" | "ui.text" + | "ui.clipboard" | "ui.scroll" => { + // Geometry and input events are resolved only after a fresh completed layout; the + // reply waits until the UI has consumed the last event (plan §4.5). + Outcome::Gesture { method: req.method.clone(), params: req.params.clone() } } + "ui.zoom" => Outcome::Gesture { method: req.method.clone(), params: req.params.clone() }, "ui.set" => { // an arrangement of the sidebar is checked, not made whole as a settings file's is let sidebar = match p.get("sidebar").map(crate::sidebar::SidebarLayout::parse).transpose() { @@ -449,12 +462,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest _ => err("ui.zoomFactor: factor must be a number from 0.5 to 3"), }, "ui.screenshot" => { - // never over a photo's original - if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) { - return err(e); - } + // Never over a photo's original, and pin the destination identity now: the write + // revalidates it after the (possibly lengthy) capture and never replaces a path that + // was absent at admission but appeared meanwhile. + let target = match s("path") { + Some(path) => match app.session.pin_write_target(path) { + Ok(t) => Some(t), + Err(e) => return err(e), + }, + None => None, + }; ctx.request_repaint(); - Outcome::Screenshot { path: s("path").map(str::to_string), headless: p.get("headless").and_then(Value::as_bool).unwrap_or(false) } + Outcome::Screenshot { target, headless: p.get("headless").and_then(Value::as_bool).unwrap_or(false) } } "ui.render" => { let id = match p.get("id").filter(|v| !v.is_null()) { @@ -581,22 +600,230 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result) -> Value { +/// Resolve one queued synthetic-input control request against the **current** layout. +/// +/// Called by the app only after a completed UI pass newer than admission (plan §4.5). Widget rects +/// and the image rect therefore come from that pass; nothing is retargeted. A successful return +/// has pushed the events onto `app.synthetic`; the app answers the request once the UI consumed +/// them. +pub(crate) fn gesture_events(app: &mut LightcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Result { + let s = |k: &str| p.get(k).and_then(Value::as_str); + let f = |k: &str| p.get(k).and_then(Value::as_f64); + match method { + "ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" => { + let Some(id) = s("id") else { return Err("missing `id`".into()) }; + let Some(r) = widget_rect(app, id) else { return Err(format!("no widget `{id}` on screen (see ui.widgets)")) }; + let m = modifiers(p); + let (fx, fy) = (f("fx").unwrap_or(0.5), f("fy").unwrap_or(0.5)); + let at = egui::pos2(r.left() + r.width() * fx as f32, r.top() + r.height() * fy as f32); + if !fx.is_finite() || !fy.is_finite() || !at.is_finite() { + return Err(format!("{method}: fx and fy must resolve to a finite point")); + } + let hover = method == "ui.hoverWidget"; + if hover && !widget_target_visible(ctx, widget_hit(app, id), at) { + return Err(format!("ui.hoverWidget: widget `{id}` has no visible interaction area at the requested point")); + } + if hover { + app.synthetic.push(egui::Event::PointerMoved(at)); + } else if method == "ui.clickWidget" { + let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).min(MAX_CLICKS); + let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; + app.synthetic.push(egui::Event::PointerMoved(at)); + for _ in 0..n { + app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: m }); + app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: m }); + } + } else { + let to = egui::pos2( + f("toX").map(|v| v as f32).unwrap_or(at.x + f("dx").unwrap_or(0.0) as f32), + f("toY").map(|v| v as f32).unwrap_or(at.y + f("dy").unwrap_or(0.0) as f32), + ); + push_drag(app, at, to, p.get("steps").and_then(Value::as_u64).unwrap_or(10).min(MAX_DRAG_STEPS), m); + } + ctx.request_repaint(); + Ok(GestureOutcome { + result: json!({"rect": rect_json(r)}), + target: Some(GestureTarget { id: id.to_string(), rect: r, at, check_release: method == "ui.clickWidget", check_visible: hover }), + }) + } + "ui.move" => { + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32))); + ctx.request_repaint(); + Ok(GestureOutcome::done(Value::Null)) + } + "ui.click" | "ui.drag" => { + let a = egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32); + let m = modifiers(p); + let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; + if method == "ui.drag" { + let b = egui::pos2(f("toX").unwrap_or(0.0) as f32, f("toY").unwrap_or(0.0) as f32); + push_drag(app, a, b, p.get("steps").and_then(Value::as_u64).unwrap_or(10).min(MAX_DRAG_STEPS), m); + } else { + app.synthetic.push(egui::Event::PointerMoved(a)); + for _ in 0..p.get("count").and_then(Value::as_u64).unwrap_or(1).min(MAX_CLICKS) { + app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers: m }); + app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: false, modifiers: m }); + } + } + ctx.request_repaint(); + Ok(GestureOutcome::done(Value::Null)) + } + "ui.pointer" => { + let Some(img) = app.image_rect else { return Err("no image on screen (switch to Detail view)".into()) }; + let Some(events) = p.get("events").and_then(Value::as_array) else { return Err("missing `events`".into()) }; + let m = modifiers(p); + for e in events { + let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.5) as f32; + let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.5) as f32; + let pos = egui::pos2(img.left() + x * img.width(), img.top() + y * img.height()); + match e.get("kind").and_then(Value::as_str).unwrap_or("move") { + "down" => { + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); + } + "up" => { + app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: m }) + } + _ => app.synthetic.push(egui::Event::PointerMoved(pos)), + } + } + ctx.request_repaint(); + Ok(GestureOutcome::done(json!({"events": events.len()}))) + } + "ui.key" => { + let Some(k) = s("key").and_then(key_from) else { return Err("unknown or missing `key`".into()) }; + let m = modifiers(p); + app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + ctx.request_repaint(); + Ok(GestureOutcome::done(Value::Null)) + } + "ui.text" => { + app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); + ctx.request_repaint(); + Ok(GestureOutcome::done(Value::Null)) + } + "ui.clipboard" => { + // RequestPaste is a viewport command, not an event: the prompt/library input barrier + // cannot inspect it after the fact. Refuse the command while either barrier owns input, + // including a window created after this pass began resolving the gesture. Without + // `text` this only queues RequestPaste in egui; it is not a host-dispatch or delivery + // acknowledgement. egui 0.37 documents RequestPaste as asking the host to paste into + // the focused text edit, with no completion or correlation id. egui-winit emits a later + // Event::Paste only for non-empty text (or a PasteImage fallback; Wayland reads finish + // later), so a barrier created after the request can drop that later event; the reply + // below cannot promise dispatch or delivery. With `text`, the synthetic + // Event::Paste is filtered and canceled like other injected input. + let barrier = if crate::panels::notices::blocks_input(app) { + Some("a modal prompt") + } else if crate::panels::library_problem::blocks_dialog_input(app) { + Some("the blocking library window") + } else { + None + }; + if let Some(barrier) = barrier { + return Err(format!("ui.clipboard: {barrier} is blocking background input")); + } + if !ctx.text_edit_focused() { + return Err("ui.clipboard: no text field has the focus (click one first)".into()); + } + match s("action") { + Some("cut") => app.synthetic.push(egui::Event::Cut), + Some("copy") => app.synthetic.push(egui::Event::Copy), + Some("paste") => match s("text") { + Some(text) => app.synthetic.push(egui::Event::Paste(text.to_string())), + None => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + }, + _ => return Err("ui.clipboard: `action` is cut, copy or paste".into()), + } + ctx.request_repaint(); + Ok(GestureOutcome::done(Value::Null)) + } + "ui.scroll" => { + // Omitted keys keep the pre-0.37 request meaning; all other values are validated + // before anything is queued. + let source = match s("source") { + None | Some("unknown") => egui::MouseWheelSource::Unknown, + Some("wheel") => egui::MouseWheelSource::Wheel, + Some("trackpad") => egui::MouseWheelSource::Trackpad, + Some("momentum") => egui::MouseWheelSource::Momentum, + Some(other) => return Err(format!("ui.scroll: `source` is wheel, trackpad, momentum or unknown, not {other:?}")), + }; + let unit = match s("unit") { + None | Some("point") => egui::MouseWheelUnit::Point, + Some("line") => egui::MouseWheelUnit::Line, + Some("page") => egui::MouseWheelUnit::Page, + Some(other) => return Err(format!("ui.scroll: `unit` is point, line or page, not {other:?}")), + }; + let phase = match s("phase") { + None | Some("move") => egui::TouchPhase::Move, + Some("start") => egui::TouchPhase::Start, + Some("end") => egui::TouchPhase::End, + Some("cancel") => egui::TouchPhase::Cancel, + Some(other) => return Err(format!("ui.scroll: `phase` is start, move, end or cancel, not {other:?}")), + }; + app.synthetic.push(egui::Event::MouseWheel { + unit, + delta: egui::vec2(f("dx").unwrap_or(0.0) as f32, f("dy").unwrap_or(0.0) as f32), + phase, + source, + modifiers: modifiers(p), + }); + ctx.request_repaint(); + Ok(GestureOutcome::done(Value::Null)) + } + "ui.zoom" => { + let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else { + return Err("ui.zoom: factor must be a finite positive number (1 = unchanged)".into()); + }; + app.synthetic.push(egui::Event::Zoom(factor)); + ctx.request_repaint(); + Ok(GestureOutcome::done(Value::Null)) + } + other => Err(format!("not a synthetic-input gesture: `{other}`")), + } +} + +/// Encode `image` as PNG and write it to the capture's pinned target, returning the control +/// protocol's JSON result. The destination is revalidated before encoding; a new path is written +/// with create-new semantics (never replacing a file that appeared), and a missing encoder or +/// writer is an error. `None` only reports the image size without writing. +pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, target: Option<&lightcraft_engine::originals::WriteTarget>) -> Value { let [w, h] = image.size; - let Some(path) = path else { + let Some(target) = target else { return json!({"ok": true, "result": {"width": w, "height": h}}); }; - let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); - let Some(img) = lightcraft_raster::Rgba8::from_bytes(w, h, &rgba) else { + // Re-check the pinned destination immediately before encoding/writing. This is the point the + // asynchronous renderer callback and the CPU fallback share. + if let Err(error) = app.session.revalidate_write_target(target) { + return json!({"ok": false, "error": error}); + } + // Rgba8's buffer is the only extra full-image allocation while encoding (the PNG bytes are + // the second transient; see MAX_SHOT_WORKING_SET_BYTES in `lib.rs`). Building it straight from + // the `Color32` pixels avoids the former flat `Vec` copy of the same image. + let Some(img) = lightcraft_raster::Rgba8::from_pixels(w, h, image.pixels.iter().map(|c| c.to_array())) else { return json!({"ok": false, "error": "bad screenshot buffer"}); }; let Some(png) = app.services.png.as_ref() else { return json!({"ok": false, "error": "no encoder"}) }; let bytes = png(&img); - match app.services.write.as_mut() { - Some(wr) => match wr(path, &bytes) { + let path = target.path(); + if target.was_absent() { + // Publish a complete temp file with create-new semantics: a file that appeared after the + // revalidation above is never replaced. Without this writer the capture fails closed. + let Some(writer) = app.services.write_new.as_mut() else { + return json!({"ok": false, "error": format!("{path}: no create-new writer configured for a new screenshot path")}); + }; + match writer(path, &bytes) { Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), Err(e) => json!({"ok": false, "error": e}), - }, - None => json!({"ok": false, "error": "no writer configured"}), + } + } else { + match app.services.write.as_mut() { + Some(wr) => match wr(path, &bytes) { + Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), + Err(e) => json!({"ok": false, "error": e}), + }, + None => json!({"ok": false, "error": "no writer configured"}), + } } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/dialogs.rs similarity index 58% rename from crates/ui-egui/src/panels/dialogs.rs rename to crates/ui-egui/src/dialogs.rs index f14d7338b..772303ffb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,181 +7,53 @@ use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; -/// Rows the Rename dialog previews. -const RENAME_PREVIEW_ROWS: usize = 6; +mod confirm; +mod cull; +mod export; +mod frame; +mod rename; +mod sam_model; -/// The Rename dialog's preview: its first rows (`None` while being planned) and how many files -/// are renamed. Which names are taken is checked on disk, which can block on a slow drive, so the -/// rows are planned on a worker thread, and only when the template, start number, photos or -/// catalog change — never once per frame. -pub(crate) fn rename_preview( - app: &mut LightcraftApp, - ctx: &egui::Context, - template: &str, - start: usize, -) -> (Option>, usize) { - use std::hash::{Hash, Hasher}; - type Rows = std::sync::Arc>>>; - let ids = app.session.selected(); - let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); - let total = photos.len(); - let mut h = std::collections::hash_map::DefaultHasher::new(); - (template, start, app.session.catalog.revision, &ids).hash(&mut h); - let key = h.finish(); - let id = egui::Id::new("rename-preview-rows"); - let read = |rows: &Rows| rows.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(); - if let Some((k, rows)) = ctx.data(|d| d.get_temp::<(u64, Rows)>(id)) - && k == key - { - return (read(&rows), total); - } - let rows: Rows = Default::default(); - let first: Vec<_> = photos.into_iter().take(RENAME_PREVIEW_ROWS).collect(); - let (out, template, repaint) = (rows.clone(), template.to_string(), ctx.clone()); - let exists = app.session.media.availability.probe(); - let work = move || { - let plans = lightcraft_engine::rename::plan_rename_photos(&first, &template, start, &|f| exists(f)); - *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(plans); - repaint.request_repaint(); - }; - #[cfg(not(target_arch = "wasm32"))] - if let Err(e) = std::thread::Builder::new().name("lc-rename-preview".into()).spawn(work) { - log::warn!("rename preview: {e}"); - } - #[cfg(target_arch = "wasm32")] - work(); - ctx.data_mut(|d| d.insert_temp(id, (key, rows.clone()))); - (read(&rows), total) -} +pub(crate) use confirm::album_entries; +pub use confirm::confirm_dialog; +pub use cull::{MAX_BURST_GAP, clamp_cull_burst, cull_params, fmt_gap, parse_gap}; +use export::{ + LIMIT_KB, QUALITY, START_NUMBER, WM_IMAGE_WIDTH, WM_INSET_X, WM_INSET_Y, WM_OPACITY, WM_POSITIONS, WM_SIZE, export_dialog_params, export_size, + group_checklist, watermark_preview, +}; +pub use frame::{ConfirmStyle, confirm_style, list_height, prompt}; +use frame::{ + DIALOG_CHROME_GUESS, DIALOG_MIN_BODY, DIALOG_SCREEN_MARGIN, choices, clicked_backdrop, dialog_body, dialog_field, field, just_opened, num, + only_shows, return_confirms, trailing_button_row, +}; +pub(crate) use rename::rename_preview; +use rename::{RENAME_PREVIEW_ROWS, person_rename_plan}; +pub(crate) use sam_model::show_model_folder; +use sam_model::{sam_by_hand, sam_model_body}; +use slideshow_export::slideshow_export_body; -/// 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` = -/// full size). On a worker thread, once per photo, catalog change and size step, the cached -/// thumbnail comes first (at once, a little soft) and then a render at the preview's own pixel size -/// replaces it. The watermark is redrawn on a copy only when it, the output size or the picture -/// changes, which is quick at this size, so the preview follows the sliders. -fn watermark_preview( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - wm: &lightcraft_engine::export::Watermark, - resize: Option, -) { - use std::hash::{Hash, Hasher}; - // (pictures received so far: 1 the thumbnail, 2 the sharp render; the latest, or why none came) - type Base = std::sync::Arc>)>>; - type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); - let t = Tokens::get(ui.ctx()); - let Some(&id) = app.session.selected().first() else { return }; - let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; - let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); - let export_short = out_w.min(out_h); - // pixels across the dialog, in steps so a scroll bar or a point of layout doesn't re-render - let step = 128.0; - let px = ((ui.available_width() * ui.ctx().pixels_per_point() / step).ceil() * step).clamp(step, 2048.0) as usize; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (id, app.session.catalog.revision, px).hash(&mut h); - let key = h.finish(); - let ctx = ui.ctx().clone(); - let (base_id, shown_id) = (egui::Id::new("export-wm-preview-base"), egui::Id::new("export-wm-preview-shown")); - let base = match ctx.data(|d| d.get_temp::<(u64, Base)>(base_id)) { - Some((k, base)) if k == key => base, - _ => { - let base: Base = Default::default(); - // the thumbnail, then as wide as the dialog (a tall photo up to three times that high) - let jobs: Vec<_> = [app.session.thumb_job(id, 512), app.session.render_job(id, px, px * 3, false, true)].into_iter().flatten().collect(); - // the web build has no worker thread: a full render there would hold up the page - #[cfg(target_arch = "wasm32")] - let jobs: Vec<_> = jobs.into_iter().take(1).collect(); - if jobs.is_empty() { - *base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = (1, Some(Err("no such photo".into()))); - } else { - let (out, repaint) = (base.clone(), ctx.clone()); - let work = move || { - for job in jobs { - // (a panic in the render is its failure: `RenderJob::run`) - let r = job.run().rendered.map(|r| r.image); - let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - // a failed sharp render keeps the thumbnail - if r.is_ok() || !matches!(got.1, Some(Ok(_))) { - *got = (got.0.saturating_add(1), Some(r)); - repaint.request_repaint(); - } - } - }; - #[cfg(not(target_arch = "wasm32"))] - if let Err(e) = std::thread::Builder::new().name("lc-watermark-preview".into()).spawn(work) { - log::warn!("watermark preview: {e}"); - } - #[cfg(target_arch = "wasm32")] - work(); - } - ctx.data_mut(|d| d.insert_temp(base_id, (key, base.clone()))); - base - } - }; - let shown = ctx.data(|d| d.get_temp::(shown_id)); - let stage = base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).0; - let tex = match shown { - Some((k, g, w, s, tex)) if k == key && g == stage && &w == wm && s == export_short => tex, - _ => { - let photo_px = match &base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).1 { - Some(Ok(img)) => Ok(img.clone()), - Some(Err(e)) => Err(Some(e.clone())), - None => Err(None), - }; - let mut img = match photo_px { - Ok(img) => img, - Err(e) => { - let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; - let r = ui - .horizontal(|ui| { - if e.is_none() { - crate::progress::spinner(ui, 12.0); - } - ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)) - }) - .inner; - if let Some(e) = e { - r.on_hover_text(e); - } - return; - } - }; - lightcraft_engine::export::draw_watermark_preview(&mut img, wm, export_short); - // shown through the monitor profile like every other preview - if let Some(d) = app.renderer.display() - && let Err(e) = d.profile.from_srgb(&mut img) - { - log::warn!("display profile: {e}"); - } - let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); - let tex = match shown { - Some((.., mut tex)) => { - tex.set(color, egui::TextureOptions::LINEAR); - tex - } - None => ctx.load_texture("export-wm-preview", color, egui::TextureOptions::LINEAR), - }; - ctx.data_mut(|d| d.insert_temp(shown_id, (key, stage, wm.clone(), export_short, tex.clone()))); - tex - } - }; - let size = tex.size_vec2(); - let scale = ui.available_width() / size.x.max(1.0); - let r = ui.vertical_centered(|ui| ui.add(egui::Image::new((tex.id(), size * scale)))).inner; - crate::widgets::register(ui.ctx(), "image:exportWatermarkPreview", r.rect); -} +pub mod credits; +pub mod faces; +pub mod import; +pub mod keymap; +pub mod lightroom_destination; +pub mod merge; +pub mod print_view; +pub mod rules_editor; +pub mod settings; +pub mod slideshow_export; +pub mod updates_ui; /// About dialog tabs: (widget id suffix, label). The credits come from `crate::credits`. pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "Contributors"), ("models", "Models")]; /// Help ▸ What's New (docs/whats-new.md). -pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); +pub const WHATS_NEW: &str = include_str!("../../../docs/whats-new.md"); /// The import review and Settings keep window ids of their own, so the size each is given /// doesn't carry over to the other dialogs. fn window_id(dialog: &Dialog) -> egui::Id { - egui::Id::new(match dialog { + egui::Id::unique(match dialog { Dialog::Import { .. } => "lightcraft-import-dialog", Dialog::Print => "lightcraft-print-dialog", Dialog::Settings { .. } => "lightcraft-settings-dialog", @@ -200,19 +72,28 @@ pub(crate) fn prepare(app: &LightcraftApp, ui: &mut egui::Ui) { } } -pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { +/// Draw the open dialog. `blocked` is true when a quit prompt or settings notice was on screen at +/// the start of this frame: the dialog is still drawn (its state is preserved), but none of its raw +/// keyboard/backdrop handling may act. The caller freezes `blocked` for the whole frame so the +/// click or key that dismisses the prompt cannot also reach the dialog drawn after it. The +/// blocking library-problem/launch-list window gets the same raw-input treatment while it is +/// actually on screen over this dialog; ordinary dialogs without that window keep their normal +/// Escape and outside-click behaviour. +pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { let Some(mut dlg) = app.ui.dialog.clone() else { return }; let at_start = dlg.clone(); + let library_blocks = crate::panels::library_problem::blocks_dialog_input(app); + let raw_blocked = blocked || library_blocks; // Esc with a popup open in the dialog (a dropdown, a date picker's calendar) closes the popup // only: claimed before the dialog is drawn, since the popup is gone once it has handled the key - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && egui::Popup::is_any_open(ctx) { + if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && egui::Popup::is_any_open(ctx) { crate::widgets::take_escape(ctx); } let t = Tokens::get(ctx); let screen = ctx.content_rect(); // The backdrop is an area below the dialog window (a bare `Middle` layer painter would be // painted after every area — i.e. over the dialog too). - egui::Area::new(egui::Id::new("dialog-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(false).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("dialog-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(false).show(ctx, |ui| { ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140)); }); let mut close = false; @@ -220,7 +101,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // Return presses OK in a dialog without a text field of its own (Lightroom's default button); // a widget that has the keyboard focus (a text field, a button or checkbox reached with Tab) // takes Return itself. Consumed before the dialog is drawn, so nothing else sees it too. - if return_confirms(&dlg) + if !raw_blocked + && return_confirms(&dlg) && ctx.memory(|m| m.focused().is_none()) && !egui::Popup::is_any_open(ctx) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) @@ -330,6 +212,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { _ => 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 } => { @@ -354,10 +243,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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.ctx(), "field:cullReject", r.rect); + 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.ctx(), "check:cullPick", r.rect); + 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) @@ -370,7 +259,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) .custom_parser(crate::panels::dialogs::parse_gap), ); - crate::widgets::register(ui.ctx(), "field:cullGap", r.rect); + crate::widgets::register(ui, "field:cullGap", r.rect); let r = ui.add( egui::Slider::new(similarity, 0.70..=0.99) .clamping(egui::SliderClamping::Never) @@ -380,7 +269,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .min_decimals(2) .max_decimals(3), ); - crate::widgets::register(ui.ctx(), "field:cullSimilar", r.rect); + 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)); @@ -436,7 +325,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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.ctx(), "field:metadataSearch", r.rect); + 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(); @@ -479,7 +368,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } 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.ctx(), "field:smartName", r.rect); + 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())); @@ -533,7 +422,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { "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.ctx(), "field:captureTime", r.rect); + crate::widgets::register(ui, "field:captureTime", r.rect); }); json!({"time": time}) } @@ -599,12 +488,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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.ctx(), format!("field:labelName-{}", colour.to_lowercase()), te.rect); + 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.ctx(), "field:labelSetName", te.rect); + 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.")) @@ -614,12 +503,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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::new("rename-template"); + 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.ctx(), "field:renameTemplate", r.rect); + crate::widgets::register(ui, "field:renameTemplate", r.rect); crate::import::tag_toggle(ui, "renameTemplate") }); if tags_open { @@ -693,7 +582,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.caution), ); let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); - crate::widgets::register(ui.ctx(), "check:personMerge", r.rect); + crate::widgets::register(ui, "check:personMerge", r.rect); } } Dialog::RenameKeyword { from, to } => { @@ -742,7 +631,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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.ctx(), "check:albumIncludeSelected", r.rect); + crate::widgets::register(ui, "check:albumIncludeSelected", r.rect); } } Dialog::RenameAlbum { name, .. } => { @@ -772,10 +661,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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| { - crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |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)); }); @@ -803,7 +693,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } }); - crate::widgets::register(ui.ctx(), "combo:exportPreset", c.response.rect); + 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})) { @@ -960,11 +851,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } 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::new("wm-graphic"))).unwrap_or(false); + 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::new("wm-graphic"), graphic)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); if !graphic { wm.image.clear(); } @@ -1036,7 +927,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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::new("export-naming"); + 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); @@ -1094,7 +985,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(target_os = "macos")] { let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); - crate::widgets::register(ui.ctx(), "exportAddToPhotos", r.rect); + 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")) @@ -1155,7 +1046,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .width(f32::INFINITY) .select_on_focus(true) .show(ui); - if just_opened(ui, "keyword-tag") { + if ui.is_enabled() && just_opened(ui, "keyword-tag") { r.response.request_focus(); } if r.ending == Some(crate::text_field::Ending::Return) { @@ -1175,11 +1066,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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.ctx(), "check:keywordInside", r.rect); + 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.ctx(), "check:keywordAddToSelected", r.rect); + 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)); @@ -1190,7 +1081,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { (person, "Person", "check:keywordPerson"), ] { let r = ui.checkbox(on, crate::i18n::tr(label)); - crate::widgets::register(ui.ctx(), id, r.rect); + crate::widgets::register(ui, id, r.rect); } } Dialog::KeywordSet { replaces, name, slots, as_new } => { @@ -1200,7 +1091,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .width(f32::INFINITY) .select_on_focus(true) .show(ui); - if just_opened(ui, "keyword-set") { + if ui.is_enabled() && just_opened(ui, "keyword-set") { r.response.request_focus(); } let mut returned = r.ending == Some(crate::text_field::Ending::Return); @@ -1224,7 +1115,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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.ctx(), "check:keywordSetAsNew", r.rect); + crate::widgets::register(ui, "check:keywordSetAsNew", r.rect); } // Return in any field saves the set if returned { @@ -1286,7 +1177,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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.ctx(), "check:moveDontAsk", c.rect); + 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), @@ -1321,7 +1212,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::About => { ui.set_min_width(680.0); - let tab_id = egui::Id::new("about_tab"); + 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; @@ -1493,8 +1384,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } if let Some(w) = &shown { // (the import review keeps its hand-picked photos however the click lands) - if app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { + 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 { @@ -1507,14 +1402,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } ctx.move_to_top(w.response.layer_id); - crate::widgets::register(ctx, "dialog:window", w.response.rect); + 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 ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { + if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { close = true; } - if confirm { + 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 @@ -1585,9 +1480,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { return; } - if !close { + if !close && !blocked { let frame = ctx.cumulative_frame_nr(); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(BACKDROP_SHOWN), frame)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(BACKDROP_SHOWN), frame)); } app.ui.dialog = if close { None } else { Some(dlg) }; } @@ -1595,1031 +1490,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { /// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; -/// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and -/// released outside the window, so a slider dragged past its edge doesn't close it; a press while a -/// popup is open (a dropdown, a date picker's calendar) only closes the popup: `popup_was_open` is -/// read before the window is drawn, since drawing it already closes the popup. Only a press made -/// while the dialog is shown counts: in the frame a dialog appears, its input is the click that -/// opened it (a quick click on a slow frame presses and releases in that one frame), so it never -/// closes it. (Read from the raw events: egui forgets the press origin on release.) -fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool { - let press_id = egui::Id::new("dialog-backdrop-press"); - let frame = ctx.cumulative_frame_nr(); - // the same frame too: a second pass of it gets no input - let shown = ctx.data(|d| d.get_temp::(egui::Id::new(BACKDROP_SHOWN))).is_some_and(|f| f + 1 >= frame); - if !shown { - ctx.data_mut(|d| d.remove_temp::(press_id)); - return false; - } - let (press, release, clicked) = ctx.input(|i| { - let at = |down: bool| { - i.events.iter().rev().find_map(|e| match e { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } if *pressed == down => Some(*pos), - _ => None, - }) - }; - (at(true), at(false), i.pointer.primary_clicked()) - }); - if let Some(p) = press { - let outside = !window.contains(p) && !popup_was_open; - ctx.data_mut(|d| d.insert_temp(press_id, outside)); - } - let Some(release) = release else { return false }; - let pressed_outside = ctx.data_mut(|d| d.remove_temp::(press_id)).unwrap_or(false); - clicked && pressed_outside && !window.contains(release) -} - -/// Room kept free above and below a dialog (#781). -const DIALOG_SCREEN_MARGIN: f32 = 12.0; -/// A dialog's title bar, frame and button row before the first frame measures them. -const DIALOG_CHROME_GUESS: f32 = 100.0; -/// A dialog's options never get less room than this, however short the window. -const DIALOG_MIN_BODY: f32 = 80.0; - -/// A list that scrolls on its own never gets shorter than this to make its dialog fit. -const DIALOG_MIN_LIST: f32 = 120.0; - -fn list_shrink_id() -> egui::Id { - egui::Id::new("dialog-list-shrink") -} - -fn list_applied_id() -> egui::Id { - egui::Id::new("dialog-list-applied") -} - -/// The height for a list in a dialog that scrolls on its own (What's New, All Metadata, the -/// shortcuts, smart-album rules): `preferred`, less what the dialog needs to fit the window, so -/// on a short window the list gets shorter rather than scrolling inside a scrolling dialog. -pub fn list_height(ui: &egui::Ui, preferred: f32) -> f32 { - let shrink = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); - let height = (preferred - shrink).max(preferred.min(DIALOG_MIN_LIST)); - ui.data_mut(|d| *d.get_temp_mut_or_default::(list_applied_id()) += preferred - height); - height -} - -/// A dialog's options: in a vertical scroll area at most `max_height` tall when `scroll` names -/// the dialog (it shrinks to its content, so a dialog that fits looks as it would without it; -/// each dialog keeps a scroll position of its own), drawn directly otherwise. Returns the height -/// they took when scrolled. -fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: impl FnOnce(&mut egui::Ui)) -> Option { - let Some(name) = scroll else { - ui.data_mut(|d| d.remove_temp::(list_shrink_id())); - body(ui); - return None; - }; - ui.data_mut(|d| d.insert_temp(list_applied_id(), 0.0_f32)); - let top = ui.cursor().top(); - // (an auto-sized window offers its content last frame's height; the minimum lets a dialog - // that grows take its full height at once, and the area still shrinks to fit its content) - let out = egui::ScrollArea::vertical() - .id_salt(("dialog-body", name)) - .max_height(max_height) - .min_scrolled_height(max_height) - .auto_shrink([true, true]) - .show(ui, body); - // what the lists that scroll on their own gave up this frame, and what they should next frame - let applied = ui.data(|d| d.get_temp::(list_applied_id())).unwrap_or(0.0); - let was = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); - let shrink = (out.content_size.y + applied - max_height).max(0.0); - if (shrink - was).abs() > 0.5 { - ui.data_mut(|d| d.insert_temp(list_shrink_id(), shrink)); - ui.ctx().request_repaint(); - } - // (the room the scroll area took: its `inner_rect` can be taller than what it allocated) - Some(ui.min_rect().bottom() - top) -} - -/// Apply a dialog's action (also used by `ui.dialog.confirm`). -/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` -/// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). -pub fn parse_gap(text: &str) -> Option { - // the one translated text `fmt_gap` writes ("1 Tag") - if text.trim() == crate::i18n::tr("1 day") { - return Some(86400.0); - } - let mut total = 0.0; - let mut seen = false; - let mut words = text.split_whitespace().peekable(); - while let Some(word) = words.next() { - // "90s" as well as "90 s" - let split = word.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(word.len()); - let (num, unit) = word.split_at(split); - let n: f64 = num.parse().ok().filter(|n: &f64| n.is_finite())?; - let unit = if unit.is_empty() { - match words.peek() { - Some(u) if u.parse::().is_err() => words.next().unwrap_or_default(), - _ => "s", - } - } else { - unit - }; - let scale = match unit.trim_end_matches('.').to_lowercase().as_str() { - "s" | "sec" | "secs" | "second" | "seconds" => 1.0, - "m" | "min" | "mins" | "minute" | "minutes" => 60.0, - "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0, - "d" | "day" | "days" => 86400.0, - _ => return None, - }; - total += n * scale; - seen = true; - } - seen.then_some(total) -} - -/// The longest burst gap `photo.analyze` accepts: a day. -pub const MAX_BURST_GAP: f32 = 86_400.0; - -/// Keep what was typed in the Assisted Culling value fields within what `photo.analyze` accepts -/// (a gap of 0 s to a day, a similarity of 0 to 1), not within the sliders' drag range. -pub fn clamp_cull_burst(gap: &mut f32, similarity: &mut f32) { - *gap = if gap.is_finite() { gap.clamp(0.0, MAX_BURST_GAP) } else { 10.0 }; - *similarity = if similarity.is_finite() { similarity.clamp(0.0, 1.0) } else { 0.93 }; -} - -/// The `photo.analyze` parameters of the Assisted Culling dialog, its values as they are. -pub fn cull_params(reject_below: f32, pick_best: bool, burst_gap: f32, similarity: f32) -> serde_json::Value { - let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); - if reject_below > 0.0 { - p["rejectBelow"] = json!(reject_below); - } - p -} - -/// `90` → `1 min 30 s`. -pub fn fmt_gap(v: f64) -> String { - let v = v.round() as u64; - match v { - 0..60 => format!("{v} s"), - 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), - 60..3600 => format!("{} min {} s", v / 60, v % 60), - 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), - 3600..86400 if v.is_multiple_of(60) => format!("{} h {} min", v / 3600, v % 3600 / 60), - // the seconds too, so editing the text reads back the same gap - 3600..86400 => format!("{} h {} min {} s", v / 3600, v % 3600 / 60, v % 60), - _ => crate::i18n::tr("1 day").into(), - } -} - /// Whether a dialog stays open after its action succeeded (the SAM 3 dialog while the model /// downloads). pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool { matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed() } - -/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is -/// all the dialog can offer. -fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool { - !sam.installed() && !sam.download_status().running && sam.mirrors().is_empty() -} - -/// The model installation guide. -const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model"; - -/// Open the configured SAM 3 folder itself; revealing a file opens its parent instead. -pub(crate) fn show_model_folder(app: &mut LightcraftApp) -> Result { - let dir = app.session.segmenter.dir.as_ref().ok_or("no folder is set for the SAM 3 model")?; - let path = dir.to_str().ok_or("the model folder path is not valid UTF-8")?; - let open = app.services.open_with.as_mut().ok_or("opening model folders is not available here")?; - // Create only after the host action is available. Existing model files are left alone. - std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - #[cfg(target_os = "linux")] - if let Some(open_folder) = app.services.open_model_folder.clone() { - let path = path.to_string(); - let folder = path.clone(); - crate::tasks::spawn( - app, - "Show in File Manager", - Some("openModelFolder"), - move || open_folder(&folder), - |app, ctx, result| { - if let Err(e) = result { - app.toast_error(ctx, e); - } - }, - )?; - return Ok(json!({"path": path, "background": true})); - } - open(path, "")?; - Ok(json!({"path": path})) -} - -/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress. -fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) { - use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES}; - let t = Tokens::get(ui.ctx()); - let seg = &app.session.segmenter; - let d = seg.download_status(); - if seg.installed() { - ui.label(crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.")); - return; - } - let gb = |b: u64| b as f64 / 1e9; - ui.label(crate::i18n::tr( - "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.", - )); - let dir = seg.dir.as_ref().map(|d| d.display().to_string()).unwrap_or_default(); - ui.label(format!("{} {:.1} GB, {} {dir}", crate::i18n::tr("A one-time download of about"), gb(MODEL_BYTES), crate::i18n::tr("saved in"))); - ui.label( - egui::RichText::new(format!( - "{} {LICENSE_NAME} — {}", - crate::i18n::tr("Licence:"), - crate::i18n::tr("Meta's terms, not LightCraft's. Downloading it means accepting them.") - )) - .color(t.text_label), - ); - let r = ui.link(crate::i18n::tr("Read the SAM License")).on_hover_text(LICENSE_URL); - crate::widgets::register(ui.ctx(), "link:samLicense", r.rect); - if r.clicked() { - let _ = crate::links::open(app, LICENSE_URL); - } - let seg = &app.session.segmenter; - if d.running { - ui.add_space(4.0); - let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file); - crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui); - let r = ui.button(crate::i18n::tr("Cancel Download")); - crate::widgets::register(ui.ctx(), "button:samCancel", r.rect); - if r.clicked() { - app.session.segmenter.cancel_download(); - } - ui.label(egui::RichText::new(crate::i18n::tr("You can close this: the download continues, and resumes if interrupted.")).color(t.text_dim)); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); - return; - } - if seg.mirrors().is_empty() { - // nothing to download from: say how to install it by hand, with the folder and the guide a click away - let dir = seg.dir.clone(); - ui.label( - egui::RichText::new(crate::i18n::tr( - "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.", - )) - .color(t.text_dim), - ); - ui.horizontal_wrapped(|ui| { - if dir.is_some() && crate::menus::ui_enabled(app, "app.openSamModelFolder") { - let r = ui.button(crate::i18n::tr(crate::menus::reveal_label())); - crate::widgets::register(ui.ctx(), "button:samFolder", r.rect); - if r.clicked() - && let Err(e) = app.run("app.openSamModelFolder", json!({})) - { - app.toast_error(ui.ctx(), e); - } - } - let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP); - crate::widgets::register(ui.ctx(), "link:samHelp", r.rect); - if r.clicked() - && let Err(e) = crate::links::open(app, SAM_HELP) - { - app.toast(ui.ctx(), e); - } - }); - } - if let Some(e) = error.map(str::to_string).or(d.error) { - ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80))); - } -} - -/// 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 { - let r = app.run(command, params)?; - app.ui.reveal_album = r.get("id").and_then(serde_json::Value::as_u64); - Ok(r) -} - -/// The albums an Album rule in smart album `editing` picks from, in the sidebar's order: the album -/// itself and any smart album that tests it (testing it back would loop) are blocked, with why. -pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { - let editing = editing.map(lightcraft_catalog::AlbumId); - crate::album_picker::entries_from(cat, |a| match editing { - Some(e) if a.id == e => Some(crate::i18n::tr("This is the album you're editing").to_string()), - Some(e) if cat.album_reaches(a.id, e) => Some(crate::i18n::tr("It tests this album, so testing it back would loop").to_string()), - _ => None, - }) -} - -pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - match dlg { - Dialog::LightroomResult { first, .. } => { - if let Some(first) = first { - app.run("library.source", json!({"kind": "all"}))?; - let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?; - // The filter command merges a patch; these defaults are omitted by serde. - filter["only"] = json!([]); - filter["labels"] = json!([]); - filter["ruleSet"] = serde_json::Value::Null; - app.run("library.filter", filter)?; - app.run("library.select", json!({"ids": [first]}))?; - app.ui.view = crate::state::ViewMode::PhotoGrid; - } - Ok(serde_json::Value::Null) - } - Dialog::SamModel { then, .. } => { - if app.session.segmenter.installed() { - // installed: start what the user was doing - if let Some((kind, op)) = then { - crate::panels::masking::begin_ai(app, kind, op)?; - } - return Ok(serde_json::Value::Null); - } - let r = app.run("segment.model.download", json!({"acknowledged": true})); - if r.is_ok() { - app.ui.sam_downloading = true; - } - r - } - Dialog::NewAlbum { name, folder, parent, include_selected } => { - created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent})) - } - Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), - Dialog::TextPrompt { value, command, params, key, .. } => { - let mut p = params.clone(); - p[key.as_str()] = json!(value.trim()); - app.run(command, p) - } - Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run( - "photo.setCaptureTime", - match mode.as_str() { - "set" => json!({"time": time}), - "shift" => json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}), - _ => json!({"hours": zone}), - }, - ), - Dialog::LabelNames { names, save_as } => { - let mut m = serde_json::Map::new(); - for (l, n) in lightcraft_catalog::ColorLabel::ALL.iter().zip(names) { - m.insert(format!("{l:?}").to_lowercase(), if n.trim().is_empty() { serde_json::Value::Null } else { json!(n.trim()) }); - } - let r = app.run("label.setNames", json!({"names": m})); - if r.is_ok() && !save_as.trim().is_empty() { - return app.run("label.saveSet", json!({"name": save_as.trim()})); - } - r - } - Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), - Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), - Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})), - Dialog::KeywordTag { - editing, - name, - parent, - inside, - synonyms, - include_on_export, - export_containing, - export_synonyms, - person, - add_to_selected, - reveal, - } => { - let mut params = json!({ - "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), - "includeOnExport": include_on_export, - "exportContaining": export_containing, - "exportSynonyms": export_synonyms, - "person": person, - }); - match editing { - Some(keyword) => { - params["keyword"] = json!(keyword); - params["name"] = json!(name); - let r = app.run("keyword.edit", params); - if r.is_ok() { - let to = match keyword.rsplit_once('|') { - Some((parent, _)) => format!("{parent}|{}", name.trim()), - None => name.trim().to_string(), - }; - crate::panels::keyword_list::follow(app, keyword, &to); - } - r - } - None => { - params["name"] = json!(name); - params["parent"] = if *inside { json!(parent) } else { serde_json::Value::Null }; - params["addToSelected"] = json!(add_to_selected); - let r = app.run("keyword.create", params); - if *reveal && let Ok(made) = &r { - app.ui.reveal_keyword = made["keyword"].as_str().map(str::to_string); - } - r - } - } - } - Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})), - Dialog::KeywordSet { replaces, name, slots, as_new } => { - use lightcraft_catalog::keywords::same; - let name = name.trim(); - if name.is_empty() { - return Err(crate::i18n::tr("A keyword set needs a name").to_string()); - } - if same(name, lightcraft_engine::cmd::keywords::RECENT) { - return Err(crate::i18n::tr("Recent Keywords is built in: choose another name").to_string()); - } - // the set it renames: none when saving as new, or when it was deleted meanwhile (then it is - // saved anew rather than refused) - let sets = &app.session.keyword_sets; - let renames = replaces.as_deref().filter(|_| !as_new).filter(|old| sets.iter().any(|x| same(&x.name, old))); - if sets.iter().any(|x| same(&x.name, name) && !renames.is_some_and(|old| same(&x.name, old))) { - return Err(crate::i18n::tr_format!("There is a keyword set “{name}” already", name = name)); - } - let mut params = json!({"name": name, "keywords": slots}); - match renames { - Some(old) => params["replace"] = json!(old), - None => params["new"] = json!(true), - } - app.run("keyword.saveSet", params) - } - Dialog::MoveKeyword { keyword, parent } => { - let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})); - if r.is_ok() { - let leaf = keyword.rsplit('|').next().unwrap_or(keyword); - let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); - crate::panels::keyword_list::follow(app, keyword, &to); - } - r - } - Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), - Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), - Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), - Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), - Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), - Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { - app.ui.cull_burst = Some((*burst_gap, *similarity)); - app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity)) - } - Dialog::SmartRules { id, name, rules, parent } => { - // refused before anything changes (not even the name) - if let Some(problem) = rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).first() { - return Err(problem.to_string()); - } - let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; - match id { - // the name and the rules together: one undo step, both or neither - Some(id) => app.run("album.setRules", json!({"id": id, "name": name, "replace": true, "rules": {"ruleSet": rules}})), - None => created_in(app, "album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}, "parent": parent})), - } - } - Dialog::NewSmartAlbum { name, parent } => { - created_in(app, "album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }, "parent": parent})) - } - Dialog::CreatePreset { name, group, groups } => app.run( - "preset.create", - json!({ - "name": if name.trim().is_empty() { "My Preset" } else { name }, - "group": if group.trim().is_empty() { "User Presets" } else { group }, - "groups": groups, - }), - ), - Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)), - Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), - Dialog::Print => app.run("app.print", json!({})), - Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), - Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), - Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { - let mut p = export_dialog_params(opts, *full_size, resize, *limit_kb); - p["dir"] = json!(dir); - p["background"] = json!(true); - app.run("app.export", p) - } - Dialog::Merge { opts } => crate::merge::start_final(app, opts), - Dialog::Import { opts } => crate::import::start(app, opts), - Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), - Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { - // another library opened meanwhile (from the menu bar, an agent): these numbers are - // other photos there - if *library != app.session.library_generation() { - return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); - } - if *dont_ask { - app.ui.settings.ask_before_move = false; - } - crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) - } - // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it - Dialog::LightroomCatalog { catalog, .. } => { - app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) - } - d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::confirm(app, d), - Dialog::SaveEffect { name, group, fields, amount, brush } => { - crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) - } - Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), - Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), - d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } | Dialog::ViewOptions => { - Ok(serde_json::Value::Null) - } - } -} - -/// Open a one-field dialog that runs `command` with `params` + `{key: typed value}`. -pub fn prompt(app: &mut LightcraftApp, title: &str, hint: &str, value: &str, command: &str, params: serde_json::Value, key: &str) { - app.ui.dialog = - Some(Dialog::TextPrompt { title: title.into(), hint: hint.into(), value: value.into(), command: command.into(), params, key: key.into() }); -} - -/// The Export dialog's choices as `app.export` params (without the folder). -fn export_dialog_params( - opts: &lightcraft_engine::export::ExportOptions, - full_size: bool, - resize: &lightcraft_engine::export::Resize, - limit_kb: u32, -) -> serde_json::Value { - let o = lightcraft_engine::export::ExportOptions { - resize: (!full_size).then_some(*resize), - limit_kb: (limit_kb > 0).then_some(limit_kb), - ..opts.clone() - }; - o.to_json() -} - -// ------------------------------------------------------------------------------------------ dialog widgets - -/// Two columns of settings-group checkboxes (Copy Settings, Create Preset) plus All / None. -fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec) { - let key_of = |g: &SettingsGroup| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - ui.columns(2, |cols| { - for (i, g) in SettingsGroup::ALL.iter().enumerate() { - let key = key_of(g); - let mut on = groups.contains(&key); - let r = cols[i % 2].checkbox(&mut on, crate::i18n::tr(g.label())); - crate::widgets::register(&r.ctx, format!("{tag}:{key}"), r.rect); - if r.changed() { - if on { - groups.push(key); - } else { - groups.retain(|x| *x != key); - } - } - } - }); - ui.horizontal(|ui| { - let all = ui.small_button(crate::i18n::tr("All")); - crate::widgets::register(ui.ctx(), format!("{tag}:all"), all.rect); - if all.clicked() { - *groups = SettingsGroup::ALL.iter().map(key_of).collect(); - } - let none = ui.small_button(crate::i18n::tr("None")); - crate::widgets::register(ui.ctx(), format!("{tag}:none"), none.rect); - if none.clicked() { - groups.clear(); - } - }); -} - -/// Width of the label column in dialogs. -const LABEL_W: f32 = 78.0; - -const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, default: f64, step: f64) -> ControlSpec { - ControlSpec { id, label, section: Section::Light, min, max, default, step, decimals: 0, track: Track::Plain } -} -const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 100.0, 90.0, 1.0); -const LIMIT_KB: ControlSpec = spec("export.limitKb", "Limit file size (KB, 0 = off)", 0.0, 20_000.0, 0.0, 10.0); -const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 16.0); -const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 16.0); -const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0); -const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) }; -const SIZE_PCT: ControlSpec = spec("export.sizePercent", "Percent", 1.0, 400.0, 50.0, 1.0); -const START_NUMBER: ControlSpec = spec("export.startNumber", "Start number", 1.0, 9999.0, 1.0, 1.0); -const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240.0, 1.0); - -/// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi. -fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::export::Resize, ppi: &mut u16) { - use lightcraft_engine::export::ResizeMode as R; - const MODES: [(R, &str); 8] = [ - (R::LongEdge, "Long Edge"), - (R::ShortEdge, "Short Edge"), - (R::Width, "Width"), - (R::Height, "Height"), - (R::WidthHeight, "Width & Height"), - (R::Dimensions, "Dimensions"), - (R::Megapixels, "Megapixels"), - (R::Percent, "Percentage"), - ]; - let r_full = ui.checkbox(full, crate::i18n::tr("Full size")); - crate::widgets::register(ui.ctx(), "check:exportFullSize", r_full.rect); - if !*full { - field(ui, "Resize to", |ui| { - let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); - let before = r.mode; - let c = crate::widgets::combo_box("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (m, l) in MODES { - ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); - } - }); - crate::widgets::register(ui.ctx(), "combo:exportResizeMode", c.response.rect); - if r.mode != before { - // a sensible value for the new unit - r.value = match r.mode { - R::Megapixels => 12.0, - R::Percent => 50.0, - _ if matches!(before, R::Megapixels | R::Percent) => 2048.0, - _ => r.value, - }; - if matches!(r.mode, R::Dimensions | R::WidthHeight) && r.height == 0 { - r.height = r.value as u32; - } - } - }); - let mut v = r.value as f64; - let spec = match r.mode { - R::Megapixels => &SIZE_MP, - R::Percent => &SIZE_PCT, - R::Dimensions | R::WidthHeight => &SIZE_W, - _ => &SIZE_PX, - }; - if num(ui, spec, &mut v) { - r.value = v as f32; - } - if matches!(r.mode, R::Dimensions | R::WidthHeight) { - let mut h = r.height as f64; - if num(ui, &SIZE_H, &mut h) { - r.height = h as u32; - } - } - let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge")); - crate::widgets::register(ui.ctx(), "check:exportDontEnlarge", c.rect); - } - let mut p = *ppi as f64; - if num(ui, &PPI, &mut p) { - *ppi = p as u16; - } -} -/// All nine positions supported by both the text and graphic watermark renderer. -/// Keep them in grid order so bottom-centre and edge-centre anchors are available in Export. -const WM_POSITIONS: [(lightcraft_engine::export::Anchor, &str); 9] = { - use lightcraft_engine::export::Anchor::*; - [(TopLeft, "↖"), (Top, "↑"), (TopRight, "↗"), (Left, "←"), (Center, "•"), (Right, "→"), (BottomLeft, "↙"), (Bottom, "↓"), (BottomRight, "↘")] -}; - -const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0); -const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); -const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); -const WM_INSET_X: ControlSpec = - ControlSpec { decimals: 1, ..spec("export.watermarkInsetX", "Horizontal inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; -const WM_INSET_Y: ControlSpec = - ControlSpec { decimals: 1, ..spec("export.watermarkInsetY", "Vertical inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; - -/// A themed slider row editing `v`; true when it changed. -fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { - match crate::widgets::slider(ui, spec, *v, true, None).value { - Some(n) => { - *v = n; - true - } - None => false, - } -} - -/// A labelled row (fixed label column). -/// View ▸ Export Slideshow…: what to write (PDF, JPEG sequence, video) and how big; where comes -/// after (a save dialog). -fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { - use lightcraft_engine::slideshow_export::{ExportSettings, Format}; - let t = Tokens::get(ui.ctx()); - let photos = app.session.visible_cloned().len(); - let slide = app.session.slideshow.slide_duration; - let (intro, ending) = app.session.slide_titles.durations(slide); - let titles = usize::from(intro.is_some()) + usize::from(ending.is_some()); - let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0); - let summary = crate::i18n::tr_format!( - "{n} photos in view · {slides} slides · {time}", - n = photos, - slides = photos + titles, - time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64) - ); - ui.label(egui::RichText::new(summary).color(t.text_dim)); - let before = settings.format; - choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format); - if settings.format != before { - *settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) }; - } - match settings.format { - Format::Video => { - choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size); - choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps); - let mut webm = settings.container == "webm"; - choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm); - settings.container = if webm { "webm".into() } else { "mp4".into() }; - } - _ => { - choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size); - } - } - field(ui, "Quality", |ui| { - let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); - crate::widgets::register(ui.ctx(), "slider:slideshowExportQuality", r.rect); - }); - let note = match settings.format { - Format::Pdf => { - let r = ui.checkbox(&mut settings.full_screen, crate::i18n::tr("Automatically show full screen")); - crate::widgets::register(ui.ctx(), "check:slideshowExportFullScreen", r.rect); - "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode." - } - Format::Jpeg => "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.", - Format::Video => "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.", - }; - ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); - ui.label( - egui::RichText::new(crate::i18n::tr("Slides use the slideshow's layout, overlays and intro and ending screens, in view order.")) - .color(t.text_dim), - ); -} - -fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(LABEL_W); - ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); - }); - add(ui) - }) - .inner -} - -/// The rest of a [`field`] row, laid out right to left: `add` places its trailing button(s) first, -/// then a text field taking exactly the width that's left. (Subtracting a guessed button width -/// instead made the auto-sized dialog grow every frame when the real button was wider, #8.) -fn trailing_button_row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { - let h = ui.spacing().interact_size.y.max(24.0); - ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), h), egui::Layout::right_to_left(egui::Align::Center), add); -} - -/// A labelled row of mutually exclusive choice buttons (ids `button:{id}-{index}`). -fn choices(ui: &mut egui::Ui, label: &str, id: &str, options: &[(V, &str)], value: &mut V) { - field(ui, label, |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (v, l)) in options.iter().enumerate() { - if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() { - *value = *v; - } - } - }); -} - -/// Dialogs that Return confirms with nothing focused (those with text fields confirm from the -/// field: `dialog_field`). -fn return_confirms(dlg: &Dialog) -> bool { - matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) -} - -struct PersonRenamePlan { - faces: usize, - needs_merge: bool, - /// Why Rename stays disabled. `None` when the typed name can be applied. - block: Option<&'static str>, -} - -/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`. -fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan { - let to_trim = to.trim(); - if to_trim.is_empty() { - return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") }; - } - if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) { - return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") }; - } - let from_key = from.trim().to_lowercase(); - let to_key = to_trim.to_lowercase(); - let mut matched = 0usize; - let mut faces = 0usize; - let mut needs_merge = false; - if !from_key.is_empty() { - for photo in cat.photos().filter(|p| p.in_library()) { - for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { - let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; - let key = name.to_lowercase(); - if key == from_key { - matched += 1; - if region.name.as_deref() != Some(to_trim) { - faces += 1; - } - } else if key == to_key { - needs_merge = true; - } - } - } - } - let block = if matched == 0 { - Some("No person by that name") - } else if faces == 0 { - Some("That is already their name") - } else { - None - }; - // a case-only change is the same person, not a merge - PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } -} - -/// A dialog's one text field: it takes the focus when the dialog opens, and opens with its text -/// selected, so typing replaces it. It takes the focus back only while nothing else has it: taking -/// it back every frame would keep Tab from ever reaching the dialog's buttons and check boxes. -/// True when Return confirms the dialog; Esc cancels the dialog (`show`). -fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool { - let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui); - // (and takes it back when nothing has it: after a click on the dialog's background or a check box, - // or when Return gave it up on a refusal; a control Tab reached keeps it) - if just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none())) { - r.response.request_focus(); - } - r.ending == Some(crate::text_field::Ending::Return) -} - -/// The dialog part `key` is drawn this frame but wasn't the frame before: the dialog has just -/// opened (to give its first field the focus once). -fn just_opened(ui: &egui::Ui, key: &str) -> bool { - let id = egui::Id::new(("dialog-shown", key)); - let pass = ui.ctx().cumulative_pass_nr(); - let last: Option = ui.data_mut(|d| d.get_temp(id)); - ui.data_mut(|d| d.insert_temp(id, pass)); - last.and_then(|l| l.checked_add(1)) != Some(pass) -} - -#[cfg(test)] -mod watermark_position_tests { - use super::WM_POSITIONS; - use lightcraft_engine::export::Anchor; - - #[test] - fn export_dialog_offers_every_rendered_anchor_exactly_once() { - let anchors = [ - Anchor::TopLeft, - Anchor::Top, - Anchor::TopRight, - Anchor::Left, - Anchor::Center, - Anchor::Right, - Anchor::BottomLeft, - Anchor::Bottom, - Anchor::BottomRight, - ]; - assert_eq!(WM_POSITIONS.len(), anchors.len()); - for anchor in anchors { - assert_eq!(WM_POSITIONS.iter().filter(|(position, _)| *position == anchor).count(), 1); - } - } -} - -/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it -/// makes or brings in something, plain when it deletes or removes (never the eye-catching -/// default) or when the dialog only shows something (its Close). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ConfirmStyle { - Primary, - Plain, -} - -pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { - match dlg { - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { - ConfirmStyle::Plain - } - _ if only_shows(dlg) => ConfirmStyle::Plain, - _ => ConfirmStyle::Primary, - } -} - -/// A dialog that only shows something: one Close, nothing to confirm or cancel. (Synchronize -/// Folder is one when its folder turned out up to date.) -fn only_shows(dlg: &Dialog) -> bool { - matches!( - dlg, - Dialog::About - | Dialog::Shortcuts - | Dialog::Settings { .. } - | Dialog::SystemInfo { .. } - | Dialog::AllMetadata { .. } - | Dialog::WhatsNew - | Dialog::ManageEffects { .. } - | Dialog::ViewOptions - ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date()) -} - -#[cfg(test)] -mod gap_tests { - use super::{fmt_gap, parse_gap}; - - #[test] - fn gap_text_reads_back_what_it_shows() { - for v in [0.0, 1.0, 12.0, 59.0, 60.0, 90.0, 600.0, 3599.0, 3600.0, 3601.0, 3661.0, 3900.0, 7325.0, 86340.0, 86399.0] { - assert_eq!(parse_gap(&fmt_gap(v)), Some(v), "{}", fmt_gap(v)); - } - assert_eq!(parse_gap("1 day"), Some(86400.0)); - assert_eq!(parse_gap("45"), Some(45.0)); - assert_eq!(parse_gap(" 45s "), Some(45.0)); - assert_eq!(parse_gap("2min 5s"), Some(125.0)); - for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { - assert_eq!(parse_gap(bad), None, "{bad:?}"); - } - // a day reads back in the UI language too ("1 Tag") - crate::i18n::set_language(crate::i18n::Locale::De); - let shown = fmt_gap(86400.0); - let back = parse_gap(&shown); - crate::i18n::set_language(crate::i18n::Locale::En); - assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); - } - - #[test] - fn gaps_past_the_slider_read_back_too() { - // the Assisted Culling slider drags to 10 min; its value field takes up to a day - for (v, text) in [(1800.0, "30 min"), (5400.0, "1 h 30 min"), (3601.0, "1 h 0 min 1 s"), (86400.0, "1 day")] { - assert_eq!(fmt_gap(v), text); - assert_eq!(parse_gap(text), Some(v), "{text}"); - } - assert_eq!(parse_gap("1800"), Some(1800.0)); - assert_eq!(parse_gap("0"), Some(0.0)); - } -} - -#[cfg(test)] -mod cull_dialog_tests { - use std::time::Duration; - - use serde_json::json; - - use super::{clamp_cull_burst, cull_params}; - use crate::headless::Headless; - use crate::state::Dialog; - use crate::{LightcraftApp, Services}; - - const T: Duration = Duration::from_secs(20); - - #[test] - fn typed_values_are_kept_within_what_photo_analyze_accepts() { - for (gap, sim) in [(0.0, 0.0), (1800.0, 1.0), (86400.0, 0.5), (0.5, 0.995)] { - let (mut g, mut s) = (gap, sim); - clamp_cull_burst(&mut g, &mut s); - assert_eq!((g, s), (gap, sim), "in range: kept as typed"); - } - for ((gap, sim), want) in [((-5.0, -0.1), (0.0, 0.0)), ((90000.0, 1.5), (86400.0, 1.0)), ((f32::NAN, f32::INFINITY), (10.0, 0.93))] { - let (mut g, mut s) = (gap, sim); - clamp_cull_burst(&mut g, &mut s); - assert_eq!((g, s), want); - } - assert_eq!(cull_params(0.0, true, 1800.0, 1.0), json!({"pickBest": true, "burstGap": 1800.0, "similarity": 1.0})); - assert_eq!(cull_params(20.0, false, 0.0, 0.7)["rejectBelow"], 20.0); - } - - /// Click the value field right of a slider registered as `id`, type `text`, Enter. - fn type_value(h: &mut Headless, id: &str, text: &str) { - let r = h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")); - let spacing = h.view.ctx.global_style().spacing.clone(); - let x = r.left() + spacing.slider_width + spacing.item_spacing.x + 6.0; - assert_eq!(h.request("ui.click", json!({"x": x, "y": r.center().y}), T)["ok"], true); - h.step(); - for key in ["a", "Backspace"] { - assert_eq!(h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T)["ok"], true); - } - assert_eq!(h.request("ui.text", json!({"text": text}), T)["ok"], true); - assert_eq!(h.request("ui.key", json!({"key": "Enter"}), T)["ok"], true); - h.step(); - h.step(); - } - - fn burst(h: &Headless) -> (f32, f32) { - match &h.app.ui.dialog { - Some(Dialog::Cull { burst_gap, similarity, .. }) => (*burst_gap, *similarity), - d => panic!("{d:?}"), - } - } - - #[test] - fn the_culling_value_fields_take_values_past_the_sliders() { - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); - h.step(); - h.app.run("dialog.cull", json!({})).unwrap(); - for _ in 0..4 { - h.step(); - } - assert_eq!(burst(&h), (10.0, 0.93)); - // half an hour and "only identical shots": past the sliders' 10 min and 0.99 - type_value(&mut h, "field:cullGap", "30 min"); - type_value(&mut h, "field:cullSimilar", "1.0"); - assert_eq!(burst(&h), (1800.0, 1.0)); - // a day is as far as photo.analyze goes; more is held there - type_value(&mut h, "field:cullGap", "2 days"); - assert_eq!(burst(&h).0, 86400.0); - type_value(&mut h, "field:cullGap", "0"); - assert_eq!(burst(&h), (0.0, 1.0)); - type_value(&mut h, "field:cullGap", "1800"); - assert_eq!(burst(&h).0, 1800.0); - // a similarity between the slider's hundredths is kept to the thousandth - type_value(&mut h, "field:cullSimilar", "0.995"); - assert_eq!(burst(&h).1, 0.995); - type_value(&mut h, "field:cullSimilar", "0.9951"); - assert_eq!(burst(&h).1, 0.995); - type_value(&mut h, "field:cullSimilar", "1.0"); - assert_eq!(burst(&h).1, 1.0); - // OK hands them to photo.analyze as typed (it refuses values out of its range) - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); - assert_eq!(h.app.ui.cull_burst, Some((1800.0, 1.0))); - // and the next opening starts from them, not from the sliders' range - h.app.run("dialog.cull", json!({})).unwrap(); - h.step(); - h.step(); - assert_eq!(burst(&h), (1800.0, 1.0)); - } -} diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs new file mode 100644 index 000000000..196018e55 --- /dev/null +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -0,0 +1,235 @@ +use super::cull::cull_params; +use super::export::export_dialog_params; +use super::*; + +/// 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 { + let r = app.run(command, params)?; + app.ui.reveal_album = r.get("id").and_then(serde_json::Value::as_u64); + Ok(r) +} + +/// The albums an Album rule in smart album `editing` picks from, in the sidebar's order: the album +/// itself and any smart album that tests it (testing it back would loop) are blocked, with why. +pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { + let editing = editing.map(lightcraft_catalog::AlbumId); + crate::album_picker::entries_from(cat, |a| match editing { + Some(e) if a.id == e => Some(crate::i18n::tr("This is the album you're editing").to_string()), + Some(e) if cat.album_reaches(a.id, e) => Some(crate::i18n::tr("It tests this album, so testing it back would loop").to_string()), + _ => None, + }) +} + +pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { + match dlg { + Dialog::LightroomResult { first, .. } => { + if let Some(first) = first { + app.run("library.source", json!({"kind": "all"}))?; + let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?; + // The filter command merges a patch; these defaults are omitted by serde. + filter["only"] = json!([]); + filter["labels"] = json!([]); + filter["ruleSet"] = serde_json::Value::Null; + app.run("library.filter", filter)?; + app.run("library.select", json!({"ids": [first]}))?; + app.ui.view = crate::state::ViewMode::PhotoGrid; + } + Ok(serde_json::Value::Null) + } + Dialog::SamModel { then, .. } => { + if app.session.segmenter.installed() { + // installed: start what the user was doing + if let Some((kind, op)) = then { + crate::panels::masking::begin_ai(app, kind, op)?; + } + return Ok(serde_json::Value::Null); + } + let r = app.run("segment.model.download", json!({"acknowledged": true})); + if r.is_ok() { + app.ui.sam_downloading = true; + } + r + } + Dialog::NewAlbum { name, folder, parent, include_selected } => { + created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent})) + } + Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::TextPrompt { value, command, params, key, .. } => { + let mut p = params.clone(); + p[key.as_str()] = json!(value.trim()); + app.run(command, p) + } + Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run( + "photo.setCaptureTime", + match mode.as_str() { + "set" => json!({"time": time}), + "shift" => json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}), + _ => json!({"hours": zone}), + }, + ), + Dialog::LabelNames { names, save_as } => { + let mut m = serde_json::Map::new(); + for (l, n) in lightcraft_catalog::ColorLabel::ALL.iter().zip(names) { + m.insert(format!("{l:?}").to_lowercase(), if n.trim().is_empty() { serde_json::Value::Null } else { json!(n.trim()) }); + } + let r = app.run("label.setNames", json!({"names": m})); + if r.is_ok() && !save_as.trim().is_empty() { + return app.run("label.saveSet", json!({"name": save_as.trim()})); + } + r + } + Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), + Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), + Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})), + Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + reveal, + } => { + let mut params = json!({ + "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), + "includeOnExport": include_on_export, + "exportContaining": export_containing, + "exportSynonyms": export_synonyms, + "person": person, + }); + match editing { + Some(keyword) => { + params["keyword"] = json!(keyword); + params["name"] = json!(name); + let r = app.run("keyword.edit", params); + if r.is_ok() { + let to = match keyword.rsplit_once('|') { + Some((parent, _)) => format!("{parent}|{}", name.trim()), + None => name.trim().to_string(), + }; + crate::panels::keyword_list::follow(app, keyword, &to); + } + r + } + None => { + params["name"] = json!(name); + params["parent"] = if *inside { json!(parent) } else { serde_json::Value::Null }; + params["addToSelected"] = json!(add_to_selected); + let r = app.run("keyword.create", params); + if *reveal && let Ok(made) = &r { + app.ui.reveal_keyword = made["keyword"].as_str().map(str::to_string); + } + r + } + } + } + Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})), + Dialog::KeywordSet { replaces, name, slots, as_new } => { + use lightcraft_catalog::keywords::same; + let name = name.trim(); + if name.is_empty() { + return Err(crate::i18n::tr("A keyword set needs a name").to_string()); + } + if same(name, lightcraft_engine::cmd::keywords::RECENT) { + return Err(crate::i18n::tr("Recent Keywords is built in: choose another name").to_string()); + } + // the set it renames: none when saving as new, or when it was deleted meanwhile (then it is + // saved anew rather than refused) + let sets = &app.session.keyword_sets; + let renames = replaces.as_deref().filter(|_| !as_new).filter(|old| sets.iter().any(|x| same(&x.name, old))); + if sets.iter().any(|x| same(&x.name, name) && !renames.is_some_and(|old| same(&x.name, old))) { + return Err(crate::i18n::tr_format!("There is a keyword set “{name}” already", name = name)); + } + let mut params = json!({"name": name, "keywords": slots}); + match renames { + Some(old) => params["replace"] = json!(old), + None => params["new"] = json!(true), + } + app.run("keyword.saveSet", params) + } + Dialog::MoveKeyword { keyword, parent } => { + let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})); + if r.is_ok() { + let leaf = keyword.rsplit('|').next().unwrap_or(keyword); + let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); + crate::panels::keyword_list::follow(app, keyword, &to); + } + r + } + Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), + Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), + Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), + Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), + Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { + app.ui.cull_burst = Some((*burst_gap, *similarity)); + app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity)) + } + Dialog::SmartRules { id, name, rules, parent } => { + // refused before anything changes (not even the name) + if let Some(problem) = rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).first() { + return Err(problem.to_string()); + } + let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; + match id { + // the name and the rules together: one undo step, both or neither + Some(id) => app.run("album.setRules", json!({"id": id, "name": name, "replace": true, "rules": {"ruleSet": rules}})), + None => created_in(app, "album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}, "parent": parent})), + } + } + Dialog::NewSmartAlbum { name, parent } => { + created_in(app, "album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }, "parent": parent})) + } + Dialog::CreatePreset { name, group, groups } => app.run( + "preset.create", + json!({ + "name": if name.trim().is_empty() { "My Preset" } else { name }, + "group": if group.trim().is_empty() { "User Presets" } else { group }, + "groups": groups, + }), + ), + Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)), + Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), + Dialog::Print => app.run("app.print", json!({})), + Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), + Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), + Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { + let mut p = export_dialog_params(opts, *full_size, resize, *limit_kb); + p["dir"] = json!(dir); + p["background"] = json!(true); + app.run("app.export", p) + } + Dialog::Merge { opts } => crate::merge::start_final(app, opts), + Dialog::Import { opts } => crate::import::start(app, opts), + Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { + // another library opened meanwhile (from the menu bar, an agent): these numbers are + // other photos there + if *library != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); + } + if *dont_ask { + app.ui.settings.ask_before_move = false; + } + crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) + } + // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it + Dialog::LightroomCatalog { catalog, .. } => { + app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) + } + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::confirm(app, d), + Dialog::SaveEffect { name, group, fields, amount, brush } => { + crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) + } + Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), + Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), + d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } | Dialog::ViewOptions => { + Ok(serde_json::Value::Null) + } + } +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/dialogs/credits.rs similarity index 96% rename from crates/ui-egui/src/credits.rs rename to crates/ui-egui/src/dialogs/credits.rs index d451aa79d..578616068 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -229,7 +229,7 @@ fn name_link(app: &mut LightcraftApp, ui: &mut egui::Ui, c: &Contributor, names: /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::tr("Show")); @@ -240,7 +240,7 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.separator(); ui.label(crate::i18n::tr("Sort")); - crate::widgets::combo_box("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { + let sort = crate::widgets::combo_box("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() { v.key = k; @@ -248,20 +248,21 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } }); + crate::widgets::name_combo(&sort.response, crate::i18n::tr("Sort")); let reverse = crate::widgets::arrow_button(ui, "", if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, false); - reverse.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Reverse the order"))); + reverse.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Reverse the order"))); if reverse.on_hover_text(crate::i18n::tr("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); let r = ui.selectable_label(!v.table, crate::i18n::tr("Grab bag")); - crate::widgets::register(ui.ctx(), "button:creditsGrabBag", r.rect); + crate::widgets::register(ui, "button:creditsGrabBag", r.rect); if r.clicked() { v.table = false; } let r = ui.selectable_label(v.table, crate::i18n::tr("Table")); - crate::widgets::register(ui.ctx(), "button:creditsTable", r.rect); + crate::widgets::register(ui, "button:creditsTable", r.rect); if r.clicked() { v.table = true; } diff --git a/crates/ui-egui/src/dialogs/cull.rs b/crates/ui-egui/src/dialogs/cull.rs new file mode 100644 index 000000000..2c3d6497a --- /dev/null +++ b/crates/ui-egui/src/dialogs/cull.rs @@ -0,0 +1,201 @@ +use super::*; + +/// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` +/// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). +pub fn parse_gap(text: &str) -> Option { + // the one translated text `fmt_gap` writes ("1 Tag") + if text.trim() == crate::i18n::tr("1 day") { + return Some(86400.0); + } + let mut total = 0.0; + let mut seen = false; + let mut words = text.split_whitespace().peekable(); + while let Some(word) = words.next() { + // "90s" as well as "90 s" + let split = word.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(word.len()); + let (num, unit) = word.split_at(split); + let n: f64 = num.parse().ok().filter(|n: &f64| n.is_finite())?; + let unit = if unit.is_empty() { + match words.peek() { + Some(u) if u.parse::().is_err() => words.next().unwrap_or_default(), + _ => "s", + } + } else { + unit + }; + let scale = match unit.trim_end_matches('.').to_lowercase().as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => 1.0, + "m" | "min" | "mins" | "minute" | "minutes" => 60.0, + "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0, + "d" | "day" | "days" => 86400.0, + _ => return None, + }; + total += n * scale; + seen = true; + } + seen.then_some(total) +} + +/// The longest burst gap `photo.analyze` accepts: a day. +pub const MAX_BURST_GAP: f32 = 86_400.0; + +/// Keep what was typed in the Assisted Culling value fields within what `photo.analyze` accepts +/// (a gap of 0 s to a day, a similarity of 0 to 1), not within the sliders' drag range. +pub fn clamp_cull_burst(gap: &mut f32, similarity: &mut f32) { + *gap = if gap.is_finite() { gap.clamp(0.0, MAX_BURST_GAP) } else { 10.0 }; + *similarity = if similarity.is_finite() { similarity.clamp(0.0, 1.0) } else { 0.93 }; +} + +/// The `photo.analyze` parameters of the Assisted Culling dialog, its values as they are. +pub fn cull_params(reject_below: f32, pick_best: bool, burst_gap: f32, similarity: f32) -> serde_json::Value { + let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); + if reject_below > 0.0 { + p["rejectBelow"] = json!(reject_below); + } + p +} + +/// `90` → `1 min 30 s`. +pub fn fmt_gap(v: f64) -> String { + let v = v.round() as u64; + match v { + 0..60 => format!("{v} s"), + 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), + 60..3600 => format!("{} min {} s", v / 60, v % 60), + 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), + 3600..86400 if v.is_multiple_of(60) => format!("{} h {} min", v / 3600, v % 3600 / 60), + // the seconds too, so editing the text reads back the same gap + 3600..86400 => format!("{} h {} min {} s", v / 3600, v % 3600 / 60, v % 60), + _ => crate::i18n::tr("1 day").into(), + } +} + +#[cfg(test)] +mod gap_tests { + use super::{fmt_gap, parse_gap}; + + #[test] + fn gap_text_reads_back_what_it_shows() { + for v in [0.0, 1.0, 12.0, 59.0, 60.0, 90.0, 600.0, 3599.0, 3600.0, 3601.0, 3661.0, 3900.0, 7325.0, 86340.0, 86399.0] { + assert_eq!(parse_gap(&fmt_gap(v)), Some(v), "{}", fmt_gap(v)); + } + assert_eq!(parse_gap("1 day"), Some(86400.0)); + assert_eq!(parse_gap("45"), Some(45.0)); + assert_eq!(parse_gap(" 45s "), Some(45.0)); + assert_eq!(parse_gap("2min 5s"), Some(125.0)); + for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { + assert_eq!(parse_gap(bad), None, "{bad:?}"); + } + // a day reads back in the UI language too ("1 Tag") + crate::i18n::set_language(crate::i18n::Locale::De); + let shown = fmt_gap(86400.0); + let back = parse_gap(&shown); + crate::i18n::set_language(crate::i18n::Locale::En); + assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); + } + + #[test] + fn gaps_past_the_slider_read_back_too() { + // the Assisted Culling slider drags to 10 min; its value field takes up to a day + for (v, text) in [(1800.0, "30 min"), (5400.0, "1 h 30 min"), (3601.0, "1 h 0 min 1 s"), (86400.0, "1 day")] { + assert_eq!(fmt_gap(v), text); + assert_eq!(parse_gap(text), Some(v), "{text}"); + } + assert_eq!(parse_gap("1800"), Some(1800.0)); + assert_eq!(parse_gap("0"), Some(0.0)); + } +} + +#[cfg(test)] +mod cull_dialog_tests { + use std::time::Duration; + + use serde_json::json; + + use super::{clamp_cull_burst, cull_params}; + use crate::headless::Headless; + use crate::state::Dialog; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + #[test] + fn typed_values_are_kept_within_what_photo_analyze_accepts() { + for (gap, sim) in [(0.0, 0.0), (1800.0, 1.0), (86400.0, 0.5), (0.5, 0.995)] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), (gap, sim), "in range: kept as typed"); + } + for ((gap, sim), want) in [((-5.0, -0.1), (0.0, 0.0)), ((90000.0, 1.5), (86400.0, 1.0)), ((f32::NAN, f32::INFINITY), (10.0, 0.93))] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), want); + } + assert_eq!(cull_params(0.0, true, 1800.0, 1.0), json!({"pickBest": true, "burstGap": 1800.0, "similarity": 1.0})); + assert_eq!(cull_params(20.0, false, 0.0, 0.7)["rejectBelow"], 20.0); + } + + /// Click the value field right of a slider registered as `id`, type `text`, Enter. + fn type_value(h: &mut Headless, id: &str, text: &str) { + let r = h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")); + let spacing = h.view.ctx.global_style().spacing.clone(); + let x = r.left() + spacing.slider_width + spacing.item_spacing.x + 6.0; + assert_eq!(h.request("ui.click", json!({"x": x, "y": r.center().y}), T)["ok"], true); + h.step(); + for key in ["a", "Backspace"] { + assert_eq!(h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T)["ok"], true); + } + assert_eq!(h.request("ui.text", json!({"text": text}), T)["ok"], true); + assert_eq!(h.request("ui.key", json!({"key": "Enter"}), T)["ok"], true); + h.step(); + h.step(); + } + + fn burst(h: &Headless) -> (f32, f32) { + match &h.app.ui.dialog { + Some(Dialog::Cull { burst_gap, similarity, .. }) => (*burst_gap, *similarity), + d => panic!("{d:?}"), + } + } + + #[test] + fn the_culling_value_fields_take_values_past_the_sliders() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.cull", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + assert_eq!(burst(&h), (10.0, 0.93)); + // half an hour and "only identical shots": past the sliders' 10 min and 0.99 + type_value(&mut h, "field:cullGap", "30 min"); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h), (1800.0, 1.0)); + // a day is as far as photo.analyze goes; more is held there + type_value(&mut h, "field:cullGap", "2 days"); + assert_eq!(burst(&h).0, 86400.0); + type_value(&mut h, "field:cullGap", "0"); + assert_eq!(burst(&h), (0.0, 1.0)); + type_value(&mut h, "field:cullGap", "1800"); + assert_eq!(burst(&h).0, 1800.0); + // a similarity between the slider's hundredths is kept to the thousandth + type_value(&mut h, "field:cullSimilar", "0.995"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "0.9951"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h).1, 1.0); + // OK hands them to photo.analyze as typed (it refuses values out of its range) + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert_eq!(h.app.ui.cull_burst, Some((1800.0, 1.0))); + // and the next opening starts from them, not from the sliders' range + h.app.run("dialog.cull", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(burst(&h), (1800.0, 1.0)); + } +} diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs new file mode 100644 index 000000000..56292605c --- /dev/null +++ b/crates/ui-egui/src/dialogs/export.rs @@ -0,0 +1,302 @@ +use super::frame::{field, num}; +use super::*; + +/// 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` = +/// full size). On a worker thread, once per photo, catalog change and size step, the cached +/// thumbnail comes first (at once, a little soft) and then a render at the preview's own pixel size +/// replaces it. The watermark is redrawn on a copy only when it, the output size or the picture +/// changes, which is quick at this size, so the preview follows the sliders. +pub(super) fn watermark_preview( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + wm: &lightcraft_engine::export::Watermark, + resize: Option, +) { + use std::hash::{Hash, Hasher}; + // (pictures received so far: 1 the thumbnail, 2 the sharp render; the latest, or why none came) + type Base = std::sync::Arc>)>>; + type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); + let t = Tokens::get(ui.ctx()); + let Some(&id) = app.session.selected().first() else { return }; + let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; + let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); + let export_short = out_w.min(out_h); + // pixels across the dialog, in steps so a scroll bar or a point of layout doesn't re-render + let step = 128.0; + let px = ((ui.available_width() * ui.ctx().pixels_per_point() / step).ceil() * step).clamp(step, 2048.0) as usize; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, app.session.catalog.revision, px).hash(&mut h); + let key = h.finish(); + let ctx = ui.ctx().clone(); + let (base_id, shown_id) = (egui::Id::unique("export-wm-preview-base"), egui::Id::unique("export-wm-preview-shown")); + let base = match ctx.data(|d| d.get_temp::<(u64, Base)>(base_id)) { + Some((k, base)) if k == key => base, + _ => { + let base: Base = Default::default(); + // the thumbnail, then as wide as the dialog (a tall photo up to three times that high) + let jobs: Vec<_> = [app.session.thumb_job(id, 512), app.session.render_job(id, px, px * 3, false, true)].into_iter().flatten().collect(); + // the web build has no worker thread: a full render there would hold up the page + #[cfg(target_arch = "wasm32")] + let jobs: Vec<_> = jobs.into_iter().take(1).collect(); + if jobs.is_empty() { + *base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = (1, Some(Err("no such photo".into()))); + } else { + let (out, repaint) = (base.clone(), ctx.clone()); + let work = move || { + for job in jobs { + // (a panic in the render is its failure: `RenderJob::run`) + let r = job.run().rendered.map(|r| r.image); + let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // a failed sharp render keeps the thumbnail + if r.is_ok() || !matches!(got.1, Some(Ok(_))) { + *got = (got.0.saturating_add(1), Some(r)); + repaint.request_repaint(); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-watermark-preview".into()).spawn(work) { + log::warn!("watermark preview: {e}"); + } + #[cfg(target_arch = "wasm32")] + work(); + } + ctx.data_mut(|d| d.insert_temp(base_id, (key, base.clone()))); + base + } + }; + let shown = ctx.data(|d| d.get_temp::(shown_id)); + let stage = base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).0; + let tex = match shown { + Some((k, g, w, s, tex)) if k == key && g == stage && &w == wm && s == export_short => tex, + _ => { + let photo_px = match &base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).1 { + Some(Ok(img)) => Ok(img.clone()), + Some(Err(e)) => Err(Some(e.clone())), + None => Err(None), + }; + let mut img = match photo_px { + Ok(img) => img, + Err(e) => { + let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; + let r = ui + .horizontal(|ui| { + if e.is_none() { + crate::progress::spinner(ui, 12.0); + } + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)) + }) + .inner; + if let Some(e) = e { + r.on_hover_text(e); + } + return; + } + }; + lightcraft_engine::export::draw_watermark_preview(&mut img, wm, export_short); + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("display profile: {e}"); + } + let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let tex = match shown { + Some((.., mut tex)) => { + tex.set(color, egui::TextureOptions::LINEAR); + tex + } + None => ctx.load_texture("export-wm-preview", color, egui::TextureOptions::LINEAR), + }; + ctx.data_mut(|d| d.insert_temp(shown_id, (key, stage, wm.clone(), export_short, tex.clone()))); + tex + } + }; + let size = tex.size_vec2(); + let scale = ui.available_width() / size.x.max(1.0); + let r = ui.vertical_centered(|ui| ui.add(egui::Image::new((tex.id(), size * scale)))).inner; + crate::widgets::register(ui, "image:exportWatermarkPreview", r.rect); +} + +/// The Export dialog's choices as `app.export` params (without the folder). +pub(super) fn export_dialog_params( + opts: &lightcraft_engine::export::ExportOptions, + full_size: bool, + resize: &lightcraft_engine::export::Resize, + limit_kb: u32, +) -> serde_json::Value { + let o = lightcraft_engine::export::ExportOptions { + resize: (!full_size).then_some(*resize), + limit_kb: (limit_kb > 0).then_some(limit_kb), + ..opts.clone() + }; + o.to_json() +} + +// ------------------------------------------------------------------------------------------ dialog widgets + +/// Two columns of settings-group checkboxes (Copy Settings, Create Preset) plus All / None. +pub(super) fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec) { + let key_of = |g: &SettingsGroup| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + ui.columns(2, |cols| { + for (i, g) in SettingsGroup::ALL.iter().enumerate() { + let key = key_of(g); + let mut on = groups.contains(&key); + let r = cols[i % 2].checkbox(&mut on, crate::i18n::tr(g.label())); + crate::widgets::register_response(format!("{tag}:{key}"), r.rect, &r); + if r.changed() { + if on { + groups.push(key); + } else { + groups.retain(|x| *x != key); + } + } + } + }); + ui.horizontal(|ui| { + let all = ui.small_button(crate::i18n::tr("All")); + crate::widgets::register(ui, format!("{tag}:all"), all.rect); + if all.clicked() { + *groups = SettingsGroup::ALL.iter().map(key_of).collect(); + } + let none = ui.small_button(crate::i18n::tr("None")); + crate::widgets::register(ui, format!("{tag}:none"), none.rect); + if none.clicked() { + groups.clear(); + } + }); +} + +/// Width of the label column in dialogs. +pub(super) const LABEL_W: f32 = 78.0; + +const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, default: f64, step: f64) -> ControlSpec { + ControlSpec { id, label, section: Section::Light, min, max, default, step, decimals: 0, track: Track::Plain } +} + +pub(super) const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 100.0, 90.0, 1.0); + +pub(super) const LIMIT_KB: ControlSpec = spec("export.limitKb", "Limit file size (KB, 0 = off)", 0.0, 20_000.0, 0.0, 10.0); + +const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 16.0); + +const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 16.0); + +const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0); + +const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) }; + +const SIZE_PCT: ControlSpec = spec("export.sizePercent", "Percent", 1.0, 400.0, 50.0, 1.0); + +pub(super) const START_NUMBER: ControlSpec = spec("export.startNumber", "Start number", 1.0, 9999.0, 1.0, 1.0); + +const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240.0, 1.0); + +/// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi. +pub(super) fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::export::Resize, ppi: &mut u16) { + use lightcraft_engine::export::ResizeMode as R; + const MODES: [(R, &str); 8] = [ + (R::LongEdge, "Long Edge"), + (R::ShortEdge, "Short Edge"), + (R::Width, "Width"), + (R::Height, "Height"), + (R::WidthHeight, "Width & Height"), + (R::Dimensions, "Dimensions"), + (R::Megapixels, "Megapixels"), + (R::Percent, "Percentage"), + ]; + let r_full = ui.checkbox(full, crate::i18n::tr("Full size")); + crate::widgets::register(ui, "check:exportFullSize", r_full.rect); + if !*full { + field(ui, "Resize to", |ui| { + let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); + let before = r.mode; + let c = crate::widgets::combo_box("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (m, l) in MODES { + ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); + } + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Resize")); + crate::widgets::register(ui, "combo:exportResizeMode", c.response.rect); + if r.mode != before { + // a sensible value for the new unit + r.value = match r.mode { + R::Megapixels => 12.0, + R::Percent => 50.0, + _ if matches!(before, R::Megapixels | R::Percent) => 2048.0, + _ => r.value, + }; + if matches!(r.mode, R::Dimensions | R::WidthHeight) && r.height == 0 { + r.height = r.value as u32; + } + } + }); + let mut v = r.value as f64; + let spec = match r.mode { + R::Megapixels => &SIZE_MP, + R::Percent => &SIZE_PCT, + R::Dimensions | R::WidthHeight => &SIZE_W, + _ => &SIZE_PX, + }; + if num(ui, spec, &mut v) { + r.value = v as f32; + } + if matches!(r.mode, R::Dimensions | R::WidthHeight) { + let mut h = r.height as f64; + if num(ui, &SIZE_H, &mut h) { + r.height = h as u32; + } + } + let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge")); + crate::widgets::register(ui, "check:exportDontEnlarge", c.rect); + } + let mut p = *ppi as f64; + if num(ui, &PPI, &mut p) { + *ppi = p as u16; + } +} + +/// All nine positions supported by both the text and graphic watermark renderer. +/// Keep them in grid order so bottom-centre and edge-centre anchors are available in Export. +pub(super) const WM_POSITIONS: [(lightcraft_engine::export::Anchor, &str); 9] = { + use lightcraft_engine::export::Anchor::*; + [(TopLeft, "↖"), (Top, "↑"), (TopRight, "↗"), (Left, "←"), (Center, "•"), (Right, "→"), (BottomLeft, "↙"), (Bottom, "↓"), (BottomRight, "↘")] +}; + +pub(super) const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0); + +pub(super) const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); + +pub(super) const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); + +pub(super) const WM_INSET_X: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetX", "Horizontal inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; + +pub(super) const WM_INSET_Y: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetY", "Vertical inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; + +#[cfg(test)] +mod watermark_position_tests { + use super::WM_POSITIONS; + use lightcraft_engine::export::Anchor; + + #[test] + fn export_dialog_offers_every_rendered_anchor_exactly_once() { + let anchors = [ + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, + ]; + assert_eq!(WM_POSITIONS.len(), anchors.len()); + for anchor in anchors { + assert_eq!(WM_POSITIONS.iter().filter(|(position, _)| *position == anchor).count(), 1); + } + } +} diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/dialogs/faces.rs similarity index 95% rename from crates/ui-egui/src/panels/faces.rs rename to crates/ui-egui/src/dialogs/faces.rs index 902c0fd84..1811ec0a9 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/dialogs/faces.rs @@ -11,8 +11,8 @@ use egui::RichText; use serde_json::{Value, json}; -use super::settings::{check, heading, hint}; use crate::LightcraftApp; +use crate::panels::settings::{check, heading, hint}; use crate::theme::Tokens; use crate::widgets::register; @@ -21,7 +21,7 @@ use crate::widgets::register; /// machine once they have been idle for a few seconds, or while another app has the keyboard but this window is still /// on screen; most of it while they are looking at the scan's progress. fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static str, bool) { - let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false))); + let (focused, minimized) = ctx.input(|i| (i.raw.focused, i.viewport().minimized.unwrap_or(false))); let watching = matches!(&app.ui.dialog, Some(crate::state::Dialog::Settings { tab }) if tab == "faces") || app.ui.view == crate::state::ViewMode::People; (pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching), focused && !minimized) @@ -30,7 +30,7 @@ fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static st /// The pace for other background work that reads the library's files (the focus-point reader): the face scan's, without /// the "watching" speed-up. pub(crate) fn background_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> &'static str { - let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false))); + let (focused, minimized) = ctx.input(|i| (i.raw.focused, i.viewport().minimized.unwrap_or(false))); pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, false) } @@ -58,12 +58,12 @@ fn pace_for(focused: bool, minimized: bool, worked: f64, moved: f64, watching: b const REFRESH_SECS: f64 = 1.5; fn list_id() -> egui::Id { - egui::Id::new("faces-model-list") + egui::Id::unique("faces-model-list") } /// The engine's model list, re-read at most every [`REFRESH_SECS`], or at once after an action changed it. fn models(app: &mut LightcraftApp, ctx: &egui::Context) -> Value { - let now = ctx.input(|i| i.time); + let now = app.tick_now(); let epoch = app.caches.faces_epoch; if let Some((e, at, v)) = ctx.data(|d| d.get_temp::<(u64, f64, Value)>(list_id())) && e == epoch @@ -156,7 +156,7 @@ pub fn setup_banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { ui.label(RichText::new(message).font(t.font(12.5)).color(t.text_dim)); let r = ui.button(button); - register(ui.ctx(), "faces:setup", r.rect); + register(ui, "faces:setup", r.rect); if r.clicked() { take_step(app, ui.ctx(), step); } @@ -170,7 +170,7 @@ fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Val heading(ui, t, "Processor"); let mut cpu = gpu["cpuOnly"].as_bool().unwrap_or(false); let r = ui.checkbox(&mut cpu, crate::i18n::tr("Run face models on the CPU only")); - register(ui.ctx(), "check:settings.facesCpuOnly", r.rect); + register(ui, "check:settings.facesCpuOnly", r.rect); if r.changed() { match app.run("faces.gpu", json!({"cpuOnly": cpu})) { Ok(_) => app.caches.faces_epoch += 1, @@ -194,7 +194,7 @@ fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Val crate::progress::spinner(ui, 12.0); } let r = ui.label(text); - register(ui.ctx(), format!("faces:device:{what}"), r.rect); + register(ui, format!("faces:device:{what}"), r.rect); if let Some(why) = why { r.on_hover_text(sentence(why)); } @@ -257,7 +257,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let r = ui .add_enabled(on || (runtime && usable), egui::Checkbox::new(&mut on, crate::i18n::tr("Recognise faces (experimental)"))) .on_disabled_hover_text(crate::i18n::tr("Choose a model below (Use) to start.")); - register(ui.ctx(), "check:settings.facesEnabled", r.rect); + register(ui, "check:settings.facesEnabled", r.rect); if r.changed() { match app.run("faces.enable", json!({"enabled": on})) { Ok(_) => app.caches.faces_epoch += 1, @@ -291,7 +291,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { let can = app.services.pick_model_file.is_some(); let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Add a model file…"))); - register(ui.ctx(), "faces:addModel", r.rect); + register(ui, "faces:addModel", r.rect); if r.clicked() { let _ = app.run("dialog.faceModel", json!({})); } @@ -388,14 +388,14 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if installed { let r = ui.button(crate::i18n::tr("Remove")); - register(ui.ctx(), format!("faces:remove:{id}"), r.rect); + register(ui, format!("faces:remove:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.remove", json!({"id": id})); app.caches.faces_epoch += 1; } if !selected && !detector { let r = ui.button("Use"); - register(ui.ctx(), format!("faces:use:{id}"), r.rect); + register(ui, format!("faces:use:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.select", json!({"id": id})); app.caches.faces_epoch += 1; @@ -405,7 +405,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match dl_state { Some("running") => { let r = ui.button(crate::i18n::tr("Cancel")); - register(ui.ctx(), format!("faces:cancelDownload:{id}"), r.rect); + register(ui, format!("faces:cancelDownload:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.downloadCancel", json!({"id": id})); app.caches.faces_dl_watch.retain(|w| w != &id); @@ -418,7 +418,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, let r = ui.button(crate::i18n::tr("Open page")).on_hover_text(crate::i18n::tr( "Opens the model's own page in your browser, to read about it or get the file yourself.", )); - register(ui.ctx(), format!("faces:get:{id}"), r.rect); + register(ui, format!("faces:get:{id}"), r.rect); if r.clicked() { open_page(app, url); } @@ -431,7 +431,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, if detector { "" } else { ", starts using it" } ); let r = ui.button(label).on_hover_text(tip); - register(ui.ctx(), format!("faces:download:{id}"), r.rect); + register(ui, format!("faces:download:{id}"), r.rect); if r.clicked() { open_download_dialog(app, m); } @@ -505,7 +505,7 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info } if let Some(url) = m["source"].as_str().or(m["licence"]["url"].as_str()) { let r = ui.link("Open the model's page"); - register(ui.ctx(), "faceModel:page", r.rect); + register(ui, "faceModel:page", r.rect); if r.clicked() { open_page(app, url); } @@ -647,11 +647,11 @@ pub fn name_editor( let t = Tokens::get(ctx); let mut outcome = Editor::Open; let opening = edit.fresh; - let shown = egui::Area::new(egui::Id::new("face-name-editor")).order(egui::Order::Foreground).fixed_pos(at).constrain(true).show(ctx, |ui| { + let shown = egui::Area::new(egui::Id::unique("face-name-editor")).order(egui::Order::Foreground).fixed_pos(at).constrain(true).show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(230.0); let r = ui.add(egui::TextEdit::singleline(&mut edit.text).hint_text("Name").desired_width(220.0)); - register(ui.ctx(), "field:faceName", r.rect); + register(ui, "field:faceName", r.rect); if edit.fresh { r.request_focus(); edit.fresh = false; @@ -677,14 +677,14 @@ pub fn name_editor( None => name.clone(), }; let b = ui.add(egui::Button::new(RichText::new(label).color(t.text_label)).frame(false).min_size(egui::vec2(220.0, 0.0))); - register(ui.ctx(), format!("faceName:pick:{name}"), b.rect); + register(ui, format!("faceName:pick:{name}"), b.rect); if b.clicked() { outcome = Editor::Submit(name); } } if let Some(label) = setup.short() { let b = ui.add(egui::Button::new(RichText::new(label).font(t.font(12.0)).color(t.accent)).frame(false)); - register(ui.ctx(), "faces:setup", b.rect); + register(ui, "faces:setup", b.rect); if b.clicked() { outcome = Editor::Setup; } @@ -705,9 +705,15 @@ pub fn name_editor( /// Called every frame: keeps the background face indexer going while recognition is on, and notes whether it is /// running, how many faces it has done and how many photos are left (the loupe's suggestions depend on it). +/// `pump_background` plus the UI-input half; the app's own phases keep them apart. pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { - watch_downloads(app, ctx); - let now = ctx.input(|i| i.time); + let now = app.tick_now(); + pump_ui_input(app, ctx, now); + pump_background(app, ctx, now); +} + +/// Fresh pointer/key activity (UI phase only). +pub fn pump_ui_input(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { let (working, moving) = ctx.input(|i| { let key_or_scroll = i.events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::MouseWheel { .. })); (i.pointer.any_down() || key_or_scroll, i.pointer.is_moving()) @@ -718,6 +724,11 @@ pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { if moving || working { app.caches.last_move = now; } +} + +/// Worker results and pacing on monotonic deadlines; safe while hidden. +pub fn pump_background(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { + watch_downloads(app, ctx); if now < app.caches.faces_next_pump { return; } diff --git a/crates/ui-egui/src/dialogs/frame.rs b/crates/ui-egui/src/dialogs/frame.rs new file mode 100644 index 000000000..3d8eb4ef8 --- /dev/null +++ b/crates/ui-egui/src/dialogs/frame.rs @@ -0,0 +1,214 @@ +use super::export::LABEL_W; +use super::*; + +/// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and +/// released outside the window, so a slider dragged past its edge doesn't close it; a press while a +/// popup is open (a dropdown, a date picker's calendar) only closes the popup: `popup_was_open` is +/// read before the window is drawn, since drawing it already closes the popup. Only a press made +/// while the dialog is shown counts: in the frame a dialog appears, its input is the click that +/// opened it (a quick click on a slow frame presses and releases in that one frame), so it never +/// closes it. (Read from the raw events: egui forgets the press origin on release.) +pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool { + let press_id = egui::Id::unique("dialog-backdrop-press"); + let frame = ctx.cumulative_frame_nr(); + // the same frame too: a second pass of it gets no input + let shown = ctx.data(|d| d.get_temp::(egui::Id::unique(BACKDROP_SHOWN))).is_some_and(|f| f + 1 >= frame); + if !shown { + ctx.data_mut(|d| d.remove_temp::(press_id)); + return false; + } + let (press, release, clicked) = ctx.input(|i| { + let at = |down: bool| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } if *pressed == down => Some(*pos), + _ => None, + }) + }; + (at(true), at(false), i.pointer.primary_clicked()) + }); + if let Some(p) = press { + let outside = !window.contains(p) && !popup_was_open; + ctx.data_mut(|d| d.insert_temp(press_id, outside)); + } + let Some(release) = release else { return false }; + let pressed_outside = ctx.data_mut(|d| d.remove_temp::(press_id)).unwrap_or(false); + clicked && pressed_outside && !window.contains(release) +} + +/// Room kept free above and below a dialog (#781). +pub(super) const DIALOG_SCREEN_MARGIN: f32 = 12.0; + +/// A dialog's title bar, frame and button row before the first frame measures them. +pub(super) const DIALOG_CHROME_GUESS: f32 = 100.0; + +/// A dialog's options never get less room than this, however short the window. +pub(super) const DIALOG_MIN_BODY: f32 = 80.0; + +/// A list that scrolls on its own never gets shorter than this to make its dialog fit. +const DIALOG_MIN_LIST: f32 = 120.0; + +fn list_shrink_id() -> egui::Id { + egui::Id::unique("dialog-list-shrink") +} + +fn list_applied_id() -> egui::Id { + egui::Id::unique("dialog-list-applied") +} + +/// The height for a list in a dialog that scrolls on its own (What's New, All Metadata, the +/// shortcuts, smart-album rules): `preferred`, less what the dialog needs to fit the window, so +/// on a short window the list gets shorter rather than scrolling inside a scrolling dialog. +pub fn list_height(ui: &egui::Ui, preferred: f32) -> f32 { + let shrink = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let height = (preferred - shrink).max(preferred.min(DIALOG_MIN_LIST)); + ui.data_mut(|d| *d.get_temp_mut_or_default::(list_applied_id()) += preferred - height); + height +} + +/// A dialog's options: in a vertical scroll area at most `max_height` tall when `scroll` names +/// the dialog (it shrinks to its content, so a dialog that fits looks as it would without it; +/// each dialog keeps a scroll position of its own), drawn directly otherwise. Returns the height +/// they took when scrolled. +pub(super) fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: impl FnOnce(&mut egui::Ui)) -> Option { + let Some(name) = scroll else { + ui.data_mut(|d| d.remove_temp::(list_shrink_id())); + body(ui); + return None; + }; + ui.data_mut(|d| d.insert_temp(list_applied_id(), 0.0_f32)); + let top = ui.cursor().top(); + // (an auto-sized window offers its content last frame's height; the minimum lets a dialog + // that grows take its full height at once, and the area still shrinks to fit its content) + let out = egui::ScrollArea::vertical() + .id_salt(("dialog-body", name)) + .max_height(max_height) + .min_scrolled_height(max_height) + .auto_shrink([true, true]) + .show(ui, body); + // what the lists that scroll on their own gave up this frame, and what they should next frame + let applied = ui.data(|d| d.get_temp::(list_applied_id())).unwrap_or(0.0); + let was = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let shrink = (out.content_size.y + applied - max_height).max(0.0); + if (shrink - was).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(list_shrink_id(), shrink)); + ui.ctx().request_repaint(); + } + // (the room the scroll area took: its `inner_rect` can be taller than what it allocated) + Some(ui.min_rect().bottom() - top) +} + +/// Open a one-field dialog that runs `command` with `params` + `{key: typed value}`. +pub fn prompt(app: &mut LightcraftApp, title: &str, hint: &str, value: &str, command: &str, params: serde_json::Value, key: &str) { + app.ui.dialog = + Some(Dialog::TextPrompt { title: title.into(), hint: hint.into(), value: value.into(), command: command.into(), params, key: key.into() }); +} + +/// A themed slider row editing `v`; true when it changed. +pub(super) fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { + match crate::widgets::slider(ui, spec, *v, true, None).value { + Some(n) => { + *v = n; + true + } + None => false, + } +} + +pub(super) fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(LABEL_W); + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +/// The rest of a [`field`] row, laid out right to left: `add` places its trailing button(s) first, +/// then a text field taking exactly the width that's left. (Subtracting a guessed button width +/// instead made the auto-sized dialog grow every frame when the real button was wider, #8.) +pub(super) fn trailing_button_row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { + let h = ui.spacing().interact_size.y.max(24.0); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), h), egui::Layout::right_to_left(egui::Align::Center), add); +} + +/// A labelled row of mutually exclusive choice buttons (ids `button:{id}-{index}`). +pub(super) fn choices(ui: &mut egui::Ui, label: &str, id: &str, options: &[(V, &str)], value: &mut V) { + field(ui, label, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (v, l)) in options.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() { + *value = *v; + } + } + }); +} + +/// Dialogs that Return confirms with nothing focused (those with text fields confirm from the +/// field: `dialog_field`). +pub(super) fn return_confirms(dlg: &Dialog) -> bool { + matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) +} + +/// A dialog's one text field: it takes the focus when the dialog opens, and opens with its text +/// selected, so typing replaces it. It takes the focus back only while nothing else has it: taking +/// it back every frame would keep Tab from ever reaching the dialog's buttons and check boxes. +/// True when Return confirms the dialog; Esc cancels the dialog (`show`). +pub(super) fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool { + let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui); + // (and takes it back when nothing has it: after a click on the dialog's background or a check box, + // or when Return gave it up on a refusal; a control Tab reached keeps it). A disabled dialog (a + // prompt or the blocking library window owns input) must not request the focus at all: egui's + // text edit would otherwise keep editing the draft from host text behind that window. + if ui.is_enabled() && (just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none()))) { + r.response.request_focus(); + } + r.ending == Some(crate::text_field::Ending::Return) +} + +/// The dialog part `key` is drawn this frame but wasn't the frame before: the dialog has just +/// opened (to give its first field the focus once). +pub(super) fn just_opened(ui: &egui::Ui, key: &str) -> bool { + let id = egui::Id::unique(("dialog-shown", key)); + let pass = ui.ctx().cumulative_pass_nr(); + let last: Option = ui.data_mut(|d| d.get_temp(id)); + ui.data_mut(|d| d.insert_temp(id, pass)); + last.and_then(|l| l.checked_add(1)) != Some(pass) +} + +/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it +/// makes or brings in something, plain when it deletes or removes (never the eye-catching +/// default) or when the dialog only shows something (its Close). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmStyle { + Primary, + Plain, +} + +pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { + match dlg { + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { + ConfirmStyle::Plain + } + _ if only_shows(dlg) => ConfirmStyle::Plain, + _ => ConfirmStyle::Primary, + } +} + +/// A dialog that only shows something: one Close, nothing to confirm or cancel. (Synchronize +/// Folder is one when its folder turned out up to date.) +pub(super) fn only_shows(dlg: &Dialog) -> bool { + matches!( + dlg, + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew + | Dialog::ManageEffects { .. } + | Dialog::ViewOptions + ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date()) +} diff --git a/crates/ui-egui/src/dialogs/import.rs b/crates/ui-egui/src/dialogs/import.rs new file mode 100644 index 000000000..8425c3ec9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import.rs @@ -0,0 +1,367 @@ +//! The import review dialog (File → Import Photos… / Import from Folder… / Import from Device): +//! the source it scanned (a scanned folder is *not* added to Local), the files found under it as +//! a grid of thumbnails with checkboxes (duplicates marked and unchecked; Shift-click sets a +//! range), the destination (add in place / copy or move into the library's `Originals/` or a +//! chosen folder, filed by day, by month, into one folder or by a custom folder template, +//! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing +//! runs on a worker thread (files are probed, copied or moved there) and its batches join the +//! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is +//! one undo step. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; +use lightcraft_engine::import::{ + ImportCandidate, ImportUprightMode, ImportWarning, ImportWarningCode, ScanInput, ScanOutput, ScanProgress, scan_with, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::sync::atomic::Ordering; + +use crate::LightcraftApp; +use crate::render::Slot; +use crate::theme::Tokens; +use crate::widgets::register; + +mod body; +mod scan; +mod tags; +mod task; + +#[cfg(test)] +pub(crate) use body::grid_height; +#[cfg(test)] +use body::{DEFAULT_BELOW_GRID, badge_rect, dup_counts}; +pub use body::{DEFAULT_FOLDER_TEMPLATE, body, example_destination, source_summary}; +pub(crate) use body::{DIALOG_SIZE, note_dialog_bottom}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use scan::cards_seen; +pub use scan::{ScanTask, browse, open, poll_scan, running}; +#[cfg(test)] +pub(crate) use scan::{stall_scan, stall_scan_of}; +pub(crate) use tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; +pub use task::{ImportTask, start, start_paths, tick}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImportDialog { + /// The files / folders that were scanned (shown as the source). + pub sources: Vec, + pub candidates: Vec, + pub checked: Vec, + /// Copy into the library (else add in place). + pub copy: bool, + /// With `copy`: move instead — the originals are removed from the source once each copy is + /// verified and catalogued (`library.import` mode `move`). + pub move_files: bool, + /// Existing album to add to. + pub album: Option, + /// …or a new album with this name. + pub new_album: String, + /// Preset id ("" = none). + pub preset: String, + /// Comma-separated keywords. + pub keywords: String, + /// Copy: destination folder ("" = the library's Originals/). + pub destination: String, + /// Copy: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or `custom` ([`ImportDialog::folder_template`]). + pub organize: String, + /// Copy, `custom`: the folder template, e.g. `{date:%Y}/{date:%Y%m%d}`. + pub folder_template: String, + /// Copy: file-name template for the copies ("" = keep the names). + pub rename: String, + /// Metadata preset name ("" = none). + pub metadata_preset: String, + /// Copy: raws are copied as DNG. + pub dng: bool, + /// Per candidate: the library already has it in Recently Deleted ([`ImportDialog::set_trashed`]). + pub trashed: Vec, + /// What to do with those: `restore` (the default: the photo comes back with its edits), `fresh` + /// (imported as a new photo, the old record deleted) or "" (leave them) — the `onDeleted` param. + pub on_deleted: String, + /// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]). + #[serde(skip)] + pub last_clicked: Option, + /// The library the scan was made in ([`lightcraft_engine::Session::library_generation`]): its duplicate + /// decisions and album ids belong to that catalog, so the review is not shown or confirmed in another. + #[serde(skip)] + pub library: Option, +} + +/// A candidate's file type, as the review groups them: its format, or its extension when the +/// probe named none (`JPEG` and `JPG` are one type). +fn file_type(c: &ImportCandidate) -> String { + let f = if c.format.trim().is_empty() { + std::path::Path::new(&c.path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default() + } else { + c.format.clone() + }; + match f.trim().to_uppercase().as_str() { + "JPEG" => "JPG".into(), + "TIFF" => "TIF".into(), + "" => "?".into(), + other => other.to_string(), + } +} + +impl ImportDialog { + pub fn new(candidates: Vec) -> Self { + let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); + ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() } + } + + /// Whether the library the scan was made in is still the open one (a review that names none counts). + fn in_library(&self, app: &LightcraftApp) -> bool { + self.library.is_none_or(|g| g == app.session.library_generation()) + } + /// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice + /// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by + /// mistake comes back by importing its file again (issue #706). + pub fn set_trashed(&mut self, trashed: Vec) { + self.trashed = trashed; + for i in 0..self.candidates.len() { + if !self.is_trashed(i) { + continue; + } + let on = self.checked_by_default(i); + if let Some(c) = self.checked.get_mut(i) { + *c = on; + } + } + } + /// Candidate `i` can be checked: a readable file the library doesn't have, or one whose photo + /// is in Recently Deleted — even with "Leave them", a click picks it (issue #1285). + pub fn importable(&self, i: usize) -> bool { + self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i))) + } + /// Candidate `i` is checked by Check All and the file type toggles: with "Leave them", a file in + /// Recently Deleted is only checked by hand. + pub fn checked_by_default(&self, i: usize) -> bool { + self.importable(i) && !(self.is_trashed(i) && self.on_deleted.is_empty()) + } + /// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each. + pub fn file_types(&self) -> Vec<(String, usize)> { + let mut types: std::collections::BTreeMap = Default::default(); + for c in &self.candidates { + *types.entry(file_type(c)).or_default() += 1; + } + types.into_iter().collect() + } + /// Any file of type `ty` is checked. + pub fn type_checked(&self, ty: &str) -> bool { + self.candidates.iter().zip(&self.checked).any(|(c, on)| *on && file_type(c) == ty) + } + /// Check (the importable ones) or uncheck every file of type `ty`: the raws only, say, and not + /// the JPEGs kept beside them (issue #344). + pub fn check_type(&mut self, ty: &str, on: bool) { + for i in 0..self.candidates.len() { + let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty); + let importable = self.checked_by_default(i); + if same && let Some(c) = self.checked.get_mut(i) { + *c = on && importable; + } + } + } + /// Candidate `i` is a file whose photo is in Recently Deleted. + pub fn is_trashed(&self, i: usize) -> bool { + self.trashed.get(i).copied().unwrap_or(false) + } + /// Choose what happens to the files in Recently Deleted (`""`, `restore` or `fresh`); they are + /// checked once there is a choice and unchecked again without one. + pub fn set_on_deleted(&mut self, choice: &str) { + // switching between restore and fresh keeps the user's per-cell choices + let changed = self.on_deleted.is_empty() != choice.is_empty(); + self.on_deleted = choice.to_string(); + for i in (0..self.candidates.len()).filter(|_| changed) { + if self.is_trashed(i) + && let Some(c) = self.checked.get_mut(i) + { + *c = !choice.is_empty(); + } + } + } + /// A click on candidate `i` toggles its checkbox. With `shift`, every importable candidate + /// from the last clicked one to `i` takes `i`'s new state, as in a file manager: click the + /// first, Shift-click the last, and the whole range is checked (or unchecked). + pub fn click(&mut self, i: usize, shift: bool) { + if !self.importable(i) { + return; + } + let Some(on) = self.checked.get(i).map(|c| !c) else { return }; + let from = if shift { self.last_clicked.filter(|a| *a < self.checked.len()).unwrap_or(i) } else { i }; + for k in from.min(i)..=from.max(i) { + if self.importable(k) + && let Some(c) = self.checked.get_mut(k) + { + *c = on; + } + } + self.last_clicked = Some(i); + } + pub fn selected_paths(&self) -> Vec { + self.candidates + .iter() + .zip(&self.checked) + .enumerate() + .filter(|(i, (_, on))| **on && self.importable(*i)) + .map(|(_, (c, _))| c.path.clone()) + .collect() + } + /// The `organize` param of `library.import` (`None` = the default, by day); an unusable + /// custom folder template is an error. + pub fn organize_param(&self) -> Result, String> { + match self.organize.as_str() { + "" => Ok(None), + "custom" => { + let t = self.folder_template.trim(); + if let Some(e) = lightcraft_engine::rename::folder_template_error(t) { + return Err(e); + } + // a plain folder name ("Imports") is a one-level template too + Ok(Some(if t.contains(['{', '/', '\\']) { t.to_string() } else { format!("{t}/") })) + } + o => Ok(Some(o.to_string())), + } + } + fn keywords(&self) -> Vec { + self.keywords.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_string).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grid_height_follows_the_window() { + // the grid takes what the options and buttons leave, in whole points + assert_eq!(grid_height(700.0, 300.0, 142.0), 400.0); + assert_eq!(grid_height(1000.5, 300.0, 142.0), 700.0); + // never less than one row of thumbnails + assert_eq!(grid_height(200.0, 300.0, 142.0), 142.0); + // a bad measurement doesn't take the grid away or make it endless + assert_eq!(grid_height(700.0, -50.0, 142.0), 700.0); + assert_eq!(grid_height(700.0, f32::NAN, 142.0), 700.0); + assert_eq!(grid_height(f32::INFINITY, 300.0, 142.0), DIALOG_SIZE[1] - DEFAULT_BELOW_GRID); + } + + /// Six candidates, all checked; candidate 3 is a duplicate (unchecked, not importable). + fn dialog() -> ImportDialog { + let mut candidates: Vec = (0..6).map(|i| ImportCandidate { path: format!("img{i}.jpg"), ..Default::default() }).collect(); + candidates[3].duplicate = Some("content".into()); + ImportDialog::new(candidates) + } + + /// Identical copies inside the source folder are not "already in the library" (a fresh library + /// said "8 already in the library"). + #[test] + fn copies_within_the_source_are_not_counted_as_in_the_library() { + let mut d = dialog(); + assert_eq!(dup_counts(&d), (0, 1, 0), "same bytes as an earlier file, no library photo"); + d.candidates[4].duplicate = Some("content".into()); + d.candidates[4].existing = Some(7); + d.candidates[5].duplicate = Some("path".into()); + d.candidates[5].existing = Some(8); + assert_eq!(dup_counts(&d), (2, 1, 0)); + } + + /// A toggle per file type: the raws only, without the JPEGs beside them (issue #344). + #[test] + fn file_types_check_and_uncheck_together() { + let mut candidates: Vec = + ["a.ARW", "a.JPG", "b.arw", "b.jpeg", "c.png"].iter().map(|p| ImportCandidate { path: p.to_string(), ..Default::default() }).collect(); + candidates[2].format = "ARW".into(); + candidates[3].format = "JPEG".into(); + candidates[2].duplicate = Some("content".into()); + let mut d = ImportDialog::new(candidates); + assert_eq!(d.file_types(), [("ARW".to_string(), 2), ("JPG".to_string(), 2), ("PNG".to_string(), 1)]); + assert!(d.type_checked("JPG")); + d.check_type("JPG", false); + assert_eq!(d.checked, [true, false, false, false, true]); + assert!(!d.type_checked("JPG")); + d.check_type("PNG", false); + d.check_type("ARW", true); + // the duplicate stays unchecked: it isn't importable + assert_eq!(d.checked, [true, false, false, false, false]); + d.check_type("JPG", true); + assert_eq!(d.checked, [true, true, false, true, false]); + assert_eq!(d.selected_paths(), ["a.ARW", "a.JPG", "b.jpeg"]); + // an unknown type changes nothing + d.check_type("CR3", false); + assert_eq!(d.checked, [true, true, false, true, false]); + } + + #[test] + fn click_toggles_one_candidate() { + let mut d = dialog(); + d.click(1, false); + assert_eq!(d.checked, [true, false, true, false, true, true]); + d.click(1, false); + assert_eq!(d.checked, [true, true, true, false, true, true]); + d.click(3, false); + assert_eq!(d.checked, [true, true, true, false, true, true], "a duplicate can't be checked"); + } + + #[test] + fn shift_click_sets_the_range_to_the_clicked_state() { + let mut d = dialog(); + d.click(1, false); + d.click(4, true); + assert_eq!(d.checked, [true, false, false, false, false, true], "1..=4 unchecked, the duplicate left alone"); + assert_eq!(d.selected_paths(), ["img0.jpg", "img5.jpg"]); + // backwards from the new anchor (4) re-checks 2..=4 + d.click(2, true); + assert_eq!(d.checked, [true, false, true, false, true, true]); + } + + #[test] + fn shift_click_without_an_earlier_click_toggles_one() { + let mut d = dialog(); + d.click(2, true); + assert_eq!(d.checked, [true, true, false, false, true, true]); + } + + /// With "Leave them", a file in Recently Deleted stays out of Check All and the type toggles, + /// but a click still checks it: it is imported (restored) on its own (issue #1285). + #[test] + fn leave_them_keeps_a_trashed_file_out_until_it_is_clicked() { + let mut d = dialog(); + d.set_trashed(vec![false, false, false, true, false, false]); + assert!(d.checked[3], "restored by default"); + d.set_on_deleted(""); + assert!(!d.checked[3]); + d.check_type("JPG", true); + assert!(!d.checked[3], "the type toggle leaves it out"); + d.click(3, false); + assert!(d.checked[3], "a click checks it"); + assert!(d.selected_paths().contains(&"img3.jpg".to_string())); + d.click(3, false); + assert!(!d.checked[3]); + // a plain duplicate (not in Recently Deleted) still can't be checked + d.set_trashed(vec![false; 6]); + d.click(3, false); + assert!(!d.checked[3]); + } + + /// A badge too wide to fit beside the checkbox goes under it instead of covering it (issue #1285). + #[test] + fn a_badge_never_covers_the_checkbox() { + let img = Rect::from_min_size(pos2(100.0, 50.0), vec2(116.0, 116.0)); + let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); + // "Will be restored" fits beside it, top right + let short = badge_rect(img, cb, vec2(76.0, 12.0)); + assert_eq!(short.right(), img.right() - 4.0); + assert_eq!(short.top(), img.top() + 6.0); + // "In Recently Deleted" doesn't + let long = badge_rect(img, cb, vec2(96.0, 12.0)); + assert!(!long.intersects(cb), "{long:?} covers {cb:?}"); + assert_eq!(long.right(), img.right() - 4.0); + assert!(img.contains_rect(long)); + } + + #[test] + fn shift_click_with_a_stale_anchor_toggles_one() { + let mut d = dialog(); + d.last_clicked = Some(99); + d.click(2, true); + assert_eq!(d.checked, [true, true, false, false, true, true]); + } +} diff --git a/crates/ui-egui/src/dialogs/import/body.rs b/crates/ui-egui/src/dialogs/import/body.rs new file mode 100644 index 000000000..9ba470660 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/body.rs @@ -0,0 +1,558 @@ +use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; +use super::*; + +/// 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]; + +/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last +/// frame. Their difference is the room the grid leaves below it. +const GRID_BOTTOM: &str = "import-grid-bottom"; + +const CONTENT_BOTTOM: &str = "import-content-bottom"; + +/// Room below the grid before it has been measured (more than the options and buttons take). +pub(super) const DEFAULT_BELOW_GRID: f32 = 360.0; + +/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, +/// in whole points so the window never grows by a fraction each frame, and at least one `row`. +pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { + let h = (available - below.max(0.0)).floor(); + if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) } +} + +/// Remember where the review dialog's content ends this frame (after its buttons), for +/// [`grid_height`] on the next. +pub(crate) fn note_dialog_bottom(ui: &egui::Ui) { + if !ui.is_sizing_pass() { + let y = ui.min_rect().bottom(); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y)); + } +} + +/// Skipped candidates: (already in the library, identical copies within the source, in Recently +/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with +/// the same bytes as one earlier in the list (no library photo) is a copy in the source. +pub(super) fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { + let trashed = d.trashed.iter().filter(|t| **t).count(); + let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); + (dups, in_source, trashed) +} + +/// The dialog body: options, then the candidate grid. +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { + let t = Tokens::get(ui.ctx()); + let n = d.candidates.len(); + let (dups, in_source, trashed) = dup_counts(d); + let sel = d.selected_paths().len(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); + if dups > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); + } + if in_source > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); + } + if trashed > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { + d.checked.iter_mut().for_each(|c| *c = false); + } + if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { + for i in 0..n { + d.checked[i] = d.checked_by_default(i); + } + } + }); + }); + // with more than one file type: a toggle per type checks or unchecks all of its files + let types = d.file_types(); + if types.len() > 1 { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim)); + for (ty, count) in &types { + let on = d.type_checked(ty); + let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on) + .on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" })); + if r.clicked() { + d.check_type(ty, !on); + } + } + }); + } + // candidate grid + let cell = 116.0; + let avail = ui.available_width(); + let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; + let rows = n.div_ceil(cols); + // as tall as the window leaves after the options and buttons below it (measured last frame), + // so resizing the dialog resizes the grid + let below = ui.ctx().data(|m| { + let y = |key: &str| m.get_temp::(egui::Id::unique(key)); + y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) + }); + let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); + let grid = + egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); + for i in 0..n { + let (c, r) = (i % cols, i / cols); + let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0)); + if !local.intersects(viewport.expand(cell)) { + continue; + } + let rect = local.translate(area.min.to_vec2()); + candidate_cell(app, ui, d, i, rect); + } + }); + if !ui.is_sizing_pass() { + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom())); + } + ui.add_space(4.0); + // options + if !d.sources.is_empty() { + field(ui, "Source", |ui| { + // (whether each source is a folder is checked off the UI thread, once) + let joined = d.sources.join("\n"); + let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| { + inspect_sources(&s.split('\n').map(str::to_string).collect::>()) + }) + .map(|summary| summary.display()) + .unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" })); + let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined); + register(ui, "label:importSource", r.rect); + }); + ui.label( + egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")) + .color(t.text_dim) + .small(), + ); + } + field(ui, "Transfer", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy) + .on_hover_text(crate::i18n::tr("Reference the files where they are")) + .clicked() + { + d.copy = false; + d.move_files = false; + } + let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() { + d.copy = true; + d.move_files = false; + } + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source")) + .clicked() + { + d.copy = true; + d.move_files = true; + } + }); + let r = ui.label( + egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) { + (true, false) => { + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." + } + (true, true) => { + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are." + } + _ => "Add in place: the library references the files where they are; nothing is copied or moved.", + })) + .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) + .small(), + ); + register(ui, "label:importModeHelp", r.rect); + if d.copy { + field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() }; + ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + d.destination = f; + } + if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() { + d.destination.clear(); + } + }); + field(ui, "Folders", |ui| { + let opts = [ + ("date", "By day (YYYY/YYYY-MM-DD)"), + ("month", "By month (YYYY/YYYY-MM)"), + ("flat", "Into one folder"), + ("custom", "Custom template…"), + ]; + let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; + let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); + let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (k, label) in opts { + let r = ui.selectable_label(key == k, crate::i18n::tr(label)); + register(ui, format!("button:importOrganize-{k}"), r.rect); + if r.clicked() { + d.organize = k.to_string(); + if k == "custom" && d.folder_template.trim().is_empty() { + d.folder_template = DEFAULT_FOLDER_TEMPLATE.into(); + } + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into")); + register(ui, "combo:importOrganize", combo.response.rect); + }); + if d.organize == "custom" { + let folders_id = egui::Id::unique("import-folder-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = tag_field_width(ui); + let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w)); + register(ui, "field:importFolderTemplate", r.rect); + tag_toggle(ui, "importFolders") + }); + if tags_open { + tag_help(ui, "importFolders", &mut d.folder_template, folders_id); + } + let t = Tokens::get(ui.ctx()); + match lightcraft_engine::rename::folder_template_error(&d.folder_template) { + Some(e) => { + ui.label(egui::RichText::new(e).color(t.caution)); + } + None => unknown_tags_warning(ui, &d.folder_template), + } + ui.label( + egui::RichText::new(crate::i18n::tr( + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").", + )) + .color(t.text_dim) + .small(), + ); + } + if !d.move_files { + field(ui, "Raw files", |ui| { + let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG")); + register(ui, "check:importDng", r.rect); + }); + } + let rename_id = egui::Id::unique("import-rename"); + let tags_open = field(ui, "Rename", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = tag_field_width(ui); + let r = ui.add( + egui::TextEdit::singleline(&mut d.rename) + .id(rename_id) + .hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}")) + .desired_width(w), + ); + register(ui, "field:importRename", r.rect); + tag_toggle(ui, "importRename") + }); + if tags_open { + tag_help(ui, "importRename", &mut d.rename, rename_id); + } + unknown_tags_warning(ui, &d.rename); + if let Some(example) = example_destination(app, d) { + let t = Tokens::get(ui.ctx()); + let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim)); + register(ui, "label:importExample", r.rect); + ui.label( + egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date.")) + .color(t.text_dim) + .small(), + ); + } + } + if d.trashed.iter().any(|t| *t) { + field(ui, "In Recently Deleted", |ui| { + let cur = match d.on_deleted.as_str() { + "restore" => "Restore them", + "fresh" => "Import as new", + _ => "Leave them", + }; + let mut pick = None; + let combo = crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { + if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { + pick = Some(v); + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Recently deleted files")); + if let Some(v) = pick { + d.set_on_deleted(v); + } + let t = Tokens::get(ui.ctx()); + ui.label( + egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first.")) + .color(t.text_dim) + .small(), + ); + }); + } + let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); + albums.retain(|a| a.folder || !a.smart); + // Catalog changes while the review is open must not leave a hidden, unselectable destination. + d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); + field(ui, "Album", |ui| { + ui.vertical(|ui| { + let mut chosen = d.album; + let mut extra = None; + let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); + let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) + .width(ui.available_width()) + .empty_text(empty) + .show_with_choices(ui, |ui| { + let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); + register(ui, "importAlbum:none", none.rect); + if none.clicked() { + extra = Some(false); + } + let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); + register(ui, "importAlbum:new", new.rect); + if new.clicked() { + extra = Some(true); + } + ui.separator(); + extra.is_some() + }); + if response.changed() { + d.album = chosen; + d.new_album.clear(); + } + if let Some(new) = extra { + d.album = None; + if !new { + d.new_album.clear(); + } else if d.new_album.is_empty() { + d.new_album = crate::i18n::tr("Imported Photos").into(); + } + } + if d.album.is_none() && !d.new_album.is_empty() { + let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); + register(ui, "field:importAlbumName", r.rect); + } + }); + }); + field(ui, "Preset", |ui| { + let cur = app + .session + .presets + .iter() + .find(|p| p.id == d.preset) + .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) + .unwrap_or_else(|| crate::i18n::tr("None").into()); + let combo = crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { + if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { + d.preset.clear(); + } + let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); + if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { + d.preset = pid; + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Preset")); + }); + if !app.session.metadata_presets.is_empty() { + field(ui, "Metadata", |ui| { + let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; + let combo = crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { + if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { + d.metadata_preset.clear(); + } + for m in &app.session.metadata_presets { + if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() { + d.metadata_preset = m.name.clone(); + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Metadata preset")); + }); + } + field(ui, "Keywords", |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY)); + register(ui, "field:importKeywords", r.rect); + }); +} + +/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail, +/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an +/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail +/// uses its width as height. +fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { + let t = Tokens::get(ui.ctx()); + let c = d.candidates[i].clone(); + let ok = d.importable(i); + let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); + let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click()); + crate::widgets::announce(&resp, c.name.clone()); + register(ui, format!("import:{i}"), img); + let p = ui.painter(); + p.rect_filled(img, 3.0, t.skeleton); + // thumbnail (background job; the grid only asks for the cells in view) + let slot = Slot::Import(i as u32); + if !app.renderer.textures.contains_key(&slot) + && let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64) + { + app.renderer.request_quick(slot, job, 4); + } + if let Some(tex) = app.renderer.textures.get(&slot) { + let [tw, th] = tex.size; + let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32); + let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s)); + let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) }; + p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); + } + let on = d.checked[i] && ok; + if on { + p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } + // checkbox + let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); + p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + if on { + p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); + p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); + } + let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); + let badge = match (&c.duplicate, &c.error) { + // checked with "Leave them", the file goes without `onDeleted`: the engine restores it + (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), + (Some(_), _) if in_trash => Some("In Recently Deleted"), + (Some(r), _) if r == "path" => Some("In library"), + (Some(_), _) => Some("Duplicate"), + (None, Some(_)) => Some("Unreadable"), + _ => None, + }; + if let Some(b) = badge { + let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); + let br = badge_rect(img, cb, g.size()); + p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); + p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); + } + let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::()) } else { c.name.clone() }; + p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim }); + let tip = format!( + "{}\n{} × {} · {} · {:.1} MB{}", + c.path, + c.width, + c.height, + c.format, + c.file_size as f64 / 1e6, + c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default() + ); + let resp = resp.on_hover_text(tip); + if resp.clicked() && ok { + let shift = ui.input(|input| input.modifiers.shift); + d.click(i, shift); + } +} + +/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit +/// beside it — drawn over the checkbox it hid it (issue #1285). +pub(super) fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { + let size = text + vec2(8.0, 4.0); + let left = img.right() - size.x - 4.0; + let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; + Rect::from_min_size(pos2(left, top), size) +} + +/// The folder template the Custom choice starts with (`2026/20260114/`). +pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}"; + +/// Where the first selected photo would be copied to (destination, folders, name), for the +/// dialog's example line; `None` without a photo or with an unusable folder template. +pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option { + let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?; + let organize = match d.organize_param().ok()? { + Some(o) => lightcraft_engine::import::Organize::parse(&o)?, + None => Default::default(), + }; + let mut q = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(0), + lightcraft_catalog::Source::File { path: c.path.clone() }, + &c.name, + &c.format, + 0, + 0, + // a photo without a capture time is dated by the import (now) + &(app.session.clock)(), + ); + q.captured = c.captured.clone(); + // the probe's metadata, so {camera}, {title}… preview as they will import + if let Some(info) = app.session.import_probes.get(&c.path) { + q.meta = info.meta.clone(); + } + let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) }; + let root = if d.destination.trim().is_empty() { + app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string()) + } else { + d.destination.trim().trim_end_matches(['/', '\\']).to_string() + }; + let sep = std::path::MAIN_SEPARATOR_STR; + let mut parts = vec![root]; + parts.extend(organize.folders(&q)); + parts.push(name); + Some(parts.join(sep)) +} + +/// The import source in a few words: a folder's name, a file's name, or "N files and folders". +pub fn source_summary(sources: &[String]) -> String { + inspect_sources(sources).display() +} + +/// Facts cached by the filesystem worker, formatted in the UI thread's active language. +/// Caching a translated String on the worker would always use its default language (English) +/// and also retain that language after the user changes the UI language. +#[derive(Clone)] +enum SourceSummary { + Folder(String), + File(String), + Many { files: usize, folders: usize }, +} + +impl SourceSummary { + fn display(&self) -> String { + match self { + Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name), + Self::File(name) => name.clone(), + Self::Many { files, folders } => match (files, folders) { + (_, 0) => crate::i18n::tr_format!("{} files", files), + (0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f), + (_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f), + }, + } + } +} + +fn inspect_sources(sources: &[String]) -> SourceSummary { + let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string()); + match sources { + [one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)), + [one] => SourceSummary::File(name(one)), + many => { + let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); + SourceSummary::Many { files: many.len().saturating_sub(folders), folders } + } + } +} + +/// A labelled row (fixed label column). +fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(78.0); + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs new file mode 100644 index 000000000..ee237fd5a --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/scan.rs @@ -0,0 +1,272 @@ +use super::task::ImportTask; +use super::*; + +/// A folder scan running on a worker thread (a network share can take minutes to read; the +/// window must keep answering meanwhile). +pub struct ScanTask { + progress: std::sync::Arc, + rx: std::sync::mpsc::Receiver, + /// Open the review with "copy into the library" checked (a camera / card). + pub copy: bool, + /// Open the review with this album chosen (an album's context menu ▸ Import…). + pub album: Option, + /// Browsing a folder (Local): the photos are read in place instead of opening the review. + pub(super) browse: bool, + /// What is being scanned (the review's source). + sources: Vec, + /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). + guard: lightcraft_engine::activity::TaskGuard, +} + +/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). +fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { + let label = if browse { "Reading folder" } else { "Scanning folder" }; + app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) +} + +/// An import and a Synchronize Folder never run at once: each readies its files against the +/// library as it was when it started, so both could add the same file. +pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { + if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } +} + +/// Settings → Import → "Show the import review when a camera or card is connected", called with +/// each device scan while it's on. The first look only notes the cards already in. A card that +/// shows up after that opens Import from Device once nothing else is open, and a card that is +/// taken out and put back in asks again. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec) { + let Some(known) = app.ui.known_cards.as_mut() else { + app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); + return; + }; + known.retain(|root| now.iter().any(|d| d.root == *root)); + let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; + // busy: ask when the window is free (the card isn't noted until then) + if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { + return; + } + known.push(card.root); + if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { + log::warn!("import from {}: {e}", card.name); + } +} + +/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). +pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result { + if app.scan.is_some() { + return Err("a scan is already running".into()); + } + if app.review_pending.is_some() { + return Err("an import review is waiting for the open dialog to close".into()); + } + let sources = paths.clone(); + let (input, paths) = ScanInput::new(&mut app.session, &paths); + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let job = move || { + let _ = tx.send(scan_with(input, &paths, &p)); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard }); + Ok(json!({"scanning": true})) +} + +/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at +/// once, the folder is listed and read on a worker thread (a network share can take minutes), +/// and the photos then join the view in small batches under a progress window. A browse already +/// running is replaced; the import review's scan is not. +pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> Result { + let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?; + if !dir.is_dir() { + return Err(format!("{path}: not a folder")); + } + if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { + return Err("an import is running".into()); + } + let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); + let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse); + if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { + // already reading this folder: clicking it again must not restart the progress + app.session.source = lightcraft_engine::LibrarySource::Folder; + app.session.keep_active_in_view(); + return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); + } + if let Some(t) = app.scan.take() { + t.progress.cancel.store(true, Ordering::Relaxed); + } + app.import = None; + app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); + app.session.source = lightcraft_engine::LibrarySource::Folder; + // the photos the library already knows from this folder show at once: keep or pick the active one + app.session.keep_active_in_view(); + let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let root = dir_s.clone(); + let job = move || { + let files: Vec = if subfolders { + lightcraft_engine::import::expand(&[root], None) + } else { + let mut v: Vec = std::fs::read_dir(&root) + .map(|rd| { + rd.flatten() + .map(|e| e.path()) + .filter(|f| { + f.is_file() + && lightcraft_engine::import::is_supported(f) + && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) + }) + .map(|f| f.to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default(); + v.sort(); + v + }; + let _ = tx.send(scan_with(input, &files, &p)); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + let guard = scan_guard(app, &progress, true); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); + app.renderer.forget_imports(); + Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) +} + +/// Collect a finished scan and open the review (called every frame). +pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { + show_pending_review(app, ctx); + let Some(task) = app.scan.as_ref() else { return }; + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let out = match task.rx.try_recv() { + Ok(o) => o, + Err(std::sync::mpsc::TryRecvError::Empty) => { + if task.guard.is_cancelled() { + // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while) + app.scan = None; + return; + } + let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); + task.guard.progress(done as u64, total as u64); + // while the folders are listed the total isn't known: say what is happening instead + task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); + return; + } + Err(_) => { + app.scan = None; + app.toast(ctx, crate::i18n::tr("Scan failed")); + return; + } + }; + let Some(task) = app.scan.take() else { return }; + if task.progress.cancel.load(Ordering::Relaxed) { + return; + } + app.session.import_probes = out.probes; + if task.browse { + // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so + // their untouched Local photos aren't forgotten while the folder is in use + if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { + let shown: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.note_browsed(&root, &shown); + } + // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, + // as `library.browse` forgets them (issue #1392) + if let Some(b) = app.session.browse.clone() { + let listed: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.forget_gone_from(&b.path, b.subfolders, &listed); + } + // what the library doesn't know yet joins the Local view + let queue: Vec = + out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); + if !queue.is_empty() { + let undo0 = app.session.undo.len(); + let total = queue.len(); + let _ = total; + app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true)); + } + return; + } + if out.candidates.is_empty() { + app.toast(ctx, crate::i18n::tr("No photos found")); + return; + } + app.renderer.forget_imports(); + let mut d = ImportDialog::new(out.candidates); + let trashed = d + .candidates + .iter() + .map(|c| { + c.duplicate.is_some() + && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) + }) + .collect(); + d.set_trashed(trashed); + d.copy = task.copy; + // only an album the review offers (it may have been deleted while the scan ran) + d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); + d.sources = task.sources; + d.library = Some(app.session.library_generation()); + app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); + show_pending_review(app, ctx); +} + +/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… +/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was +/// closed meanwhile (Settings can open another) is dropped. +fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.review_pending.is_none() { + return; + } + if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending + && !opts.in_library(app) + { + app.review_pending = None; + return; + } + if app.ui.dialog.is_none() { + app.ui.dialog = app.review_pending.take(); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. +#[cfg(test)] +pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box { + stall_scan_of(app, false) +} + +/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. +#[cfg(test)] +pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box { + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel::(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); + Box::new(tx) +} + +/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's +/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan +/// runs, and a browse that finishes replaces `app.import`. +pub fn running(app: &LightcraftApp) -> bool { + app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() +} + +impl ScanTask { + /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). + pub fn status(&self) -> Value { + json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)}) + } +} diff --git a/crates/ui-egui/src/dialogs/import/tags.rs b/crates/ui-egui/src/dialogs/import/tags.rs new file mode 100644 index 000000000..770bb5b30 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/tags.rs @@ -0,0 +1,95 @@ +use super::*; + +/// Reserve the actual translated tag button width beside a template field. +pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text); + (ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0) +} + +/// The "Tags" toggle beside a template field (`button:Tags`); returns whether the tag help +/// ([`tag_help`]) is open. +pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { + let id = egui::Id::unique((key, "tags-open")); + let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); + let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open) + .on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it")); + if r.clicked() { + open = !open; + ui.ctx().data_mut(|d| d.insert_temp(id, open)); + } + open +} + +/// "Unknown tag {x} stays as typed" under a template field, when it has one. +pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) { + let unknown = lightcraft_engine::rename::unknown_tokens(template); + if !unknown.is_empty() { + let s = if unknown.len() == 1 { "" } else { "s" }; + let text = crate::i18n::tr_format!( + "Unknown tag{s} {} stay{} as typed (see Tags)", + unknown.join(" "), + if unknown.len() == 1 { "s" } else { "" }, + s = s + ); + ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution)); + } +} + +/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection); +/// appended when the field never had a cursor. The cursor ends up after the tag. +pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) { + use egui::text::{CCursor, CCursorRange}; + let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default(); + let n = text.chars().count(); + let (a, b) = state.cursor.char_range().map_or((n, n), |r| { + let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n)); + (x.min(y), x.max(y)) + }); + let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + let (ba, bb) = (byte(a), byte(b)); + text.replace_range(ba..bb, tag); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count())))); + state.store(ctx, edit_id); + ctx.memory_mut(|m| m.request_focus(edit_id)); +} + +/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its +/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag +/// (`button:Tag-`) inserts it at the cursor of the field `edit_id`. +pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { + use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { + ui.label( + egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) + .color(t.text_dim) + .small(), + ); + egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { + egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { + for (i, tok) in TOKENS.iter().enumerate() { + let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( + if tok.aliases.is_empty() { + crate::i18n::tr("Insert at the cursor").to_string() + } else { + crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) + }, + ); + register(ui, format!("button:{key}Tag-{i}"), r.rect); + if r.clicked() { + insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); + } + ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label)); + ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim)); + ui.end_row(); + } + }); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); + for n in TEMPLATE_NOTES { + ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small()); + } + }); + }); +} diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs new file mode 100644 index 000000000..bcef55b11 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -0,0 +1,544 @@ +use super::scan::{busy_synchronizing, running}; +use super::*; + +/// 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`]. +#[cfg(target_arch = "wasm32")] +const BATCH: usize = 8; + +/// A running import (see the module docs). The file-system work — expanding folders, probing, +/// reading sidecars, copying or placing files — runs on a worker thread +/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the +/// UI thread between frames, so a slow drive never stalls the window. +#[derive(Debug, Default)] +pub struct ImportTask { + /// Files and folders to import (handed to the worker when the task starts). + queue: Vec, + pub total: usize, + pub done: usize, + params: Value, + pub imported: usize, + /// Brought back from Recently Deleted (`onDeleted: restore`). + pub restored: usize, + pub duplicates: usize, + /// The library photos the skipped duplicates match (shown when nothing new was added). + existing: Vec, + pub failed: usize, + /// Move: originals moved, and sources left in place (reported by the engine with a reason). + pub moved: usize, + pub kept: usize, + /// Structured diagnostics from foreign XMP develop settings. + pub warnings: Vec, + undo0: usize, + first: Option, + /// Reading a folder for the Local view: the photos stay out of the library, and nothing is + /// selected or announced as added. + pub(super) browse: bool, + /// Cancel was pressed: no further files are started; batches already readied are added. + pub cancelled: bool, + /// Auto Import (the watched folder): the selection stays as it is, a short toast when done. + auto: bool, + /// Auto Import: the selection to keep. + keep_selection: Option, + /// The selection and source as the import last left them: a change between frames is the + /// user choosing another photo or view, which the end of the import doesn't take away. + left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>, + /// The user chose another photo or view while the import ran. + moved_on: bool, + run: Option, + /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). + guard: Option, +} + +impl ImportTask { + /// An import of `paths` (files or folders) with `library.import` params (without `paths`). + pub fn new(paths: Vec, params: Value, undo0: usize, browse: bool) -> Self { + ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() } + } + + /// An Auto Import (see [`ImportTask::auto`]). + pub fn auto(mut self) -> Self { + self.auto = true; + self + } + + /// `{done, total, imported, cancelled}` for `ui.inspect`. + pub fn status(&self) -> Value { + json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled}) + } +} + +/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no +/// slow drives) inline, one batch per frame. +enum Runner { + #[cfg(not(target_arch = "wasm32"))] + Thread(std::sync::mpsc::Receiver), + #[cfg(target_arch = "wasm32")] + Inline { job: Box, queue: Vec, files: Option> }, +} + +struct ImportRun { + runner: Runner, + cancel: std::sync::Arc, + total: std::sync::Arc, + done: std::sync::Arc, + opts: lightcraft_engine::import::ImportOptions, + now: String, + album: Option, + album_name: Option, +} + +impl std::fmt::Debug for ImportRun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish() + } +} + +impl Drop for ImportRun { + /// A dropped import (replaced, or the app closing) stops its worker at the next file. + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +impl ImportRun { + fn start( + job: lightcraft_engine::import::ImportJob, + queue: Vec, + album: Option, + album_name: Option, + ctx: &egui::Context, + ) -> Result { + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (total, done) = (job.total.clone(), job.done.clone()); + let (opts, now) = (job.opts.clone(), job.now().to_string()); + let runner = Self::spawn(job, queue, cancel.clone(), ctx)?; + Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name }) + } + + #[cfg(not(target_arch = "wasm32"))] + fn spawn( + mut job: lightcraft_engine::import::ImportJob, + queue: Vec, + cancel: std::sync::Arc, + ctx: &egui::Context, + ) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = ctx.clone(); + let work = move || { + let files = job.expand(&queue); + for chunk in files.chunks(lightcraft_engine::import::batch_size()) { + if cancel.load(Ordering::Relaxed) { + break; + } + let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) { + Ok(p) => p, + Err(_) => break, // logged; the files readied so far are added + }; + if let Err(unsent) = tx.send(prepared) { + // the import was dropped: nothing will add these, so take back what a Move placed + unsent.0.rollback(); + break; + } + ctx.request_repaint(); + } + ctx.request_repaint(); + }; + std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}")) + } + + #[cfg(target_arch = "wasm32")] + fn spawn( + job: lightcraft_engine::import::ImportJob, + queue: Vec, + _cancel: std::sync::Arc, + _ctx: &egui::Context, + ) -> Result { + Ok(Runner::Inline { job: Box::new(job), queue, files: None }) + } + + /// Readied batches (at most a few per frame). `None` once the worker is done. + fn next_batches(&mut self) -> Option> { + match &mut self.runner { + #[cfg(not(target_arch = "wasm32"))] + Runner::Thread(rx) => { + let mut out = Vec::new(); + while out.len() < 4 { + match rx.try_recv() { + Ok(p) => out.push(p), + Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out), + Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) }, + } + } + Some(out) + } + #[cfg(target_arch = "wasm32")] + Runner::Inline { job, queue, files } => { + if self.cancel.load(Ordering::Relaxed) { + return None; + } + if files.is_none() { + *files = Some(job.expand(queue).into()); + return Some(Vec::new()); + } + let Some(files) = files.as_mut() else { return None }; + if files.is_empty() { + return None; + } + let n = files.len().min(BATCH); + let batch: Vec = files.drain(..n).collect(); + Some(vec![job.prepare_files(batch, &self.cancel)]) + } + } + } +} + +/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another +/// import runs: replacing `app.import` would stop its worker. +pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { + busy_synchronizing(app)?; + if !d.in_library(app) { + return Err("another library was opened since the photos were scanned: nothing was imported".into()); + } + // a browse scan that finishes starts its own import, replacing this one + if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { + return Err(crate::i18n::tr("An import is running").to_string()); + } + let queue = d.selected_paths(); + if queue.is_empty() { + return Err("no photos selected".into()); + } + let undo0 = app.session.undo.len(); + let mut album = d.album; + if album.is_none() && !d.new_album.trim().is_empty() { + let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?; + album = r["id"].as_u64(); + } + let mode = match (d.copy, d.move_files) { + (false, _) => "add", + (true, false) => "copy", + (true, true) => "move", + }; + let mut params = json!({"mode": mode, "keywords": d.keywords()}); + if !d.on_deleted.is_empty() { + params["onDeleted"] = json!(d.on_deleted); + } + if let Some(a) = album { + params["album"] = json!(a); + } + if !d.preset.is_empty() { + params["preset"] = json!(d.preset); + } + if !d.metadata_preset.is_empty() { + params["metadataPreset"] = json!(d.metadata_preset); + } + if d.copy { + if !d.destination.trim().is_empty() { + params["destination"] = json!(d.destination.trim()); + } + if let Some(o) = d.organize_param()? { + params["organize"] = json!(o); + } + if !d.rename.trim().is_empty() { + params["rename"] = json!(d.rename.trim()); + params["renameStart"] = json!(1); + } + if d.dng && !d.move_files { + params["dng"] = json!(true); + } + } + let total = queue.len(); + app.import = Some(ImportTask::new(queue, params, undo0, false)); + app.renderer.forget_imports(); + Ok(json!({"importing": total})) +} + +/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. +pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { + busy_synchronizing(app)?; + if running(app) { + return Err(crate::i18n::tr("An import is running").to_string()); + } + let undo0 = app.session.undo.len(); + let total = paths.len(); + app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false)); + Ok(json!({"importing": total})) +} + +/// Advance the import in progress (called every frame): start its worker, then add the batches it +/// has readied to the catalog. +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(mut task) = app.import.take() else { return }; + if task.run.is_none() { + let mut p = task.params.clone(); + p["paths"] = json!(task.queue); + let started = lightcraft_engine::cmd::library::import_params(&app.session, &p) + .and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name))) + .map_err(|e| e.to_string()) + .and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx)); + if task.auto { + task.keep_selection = Some(app.session.selection.clone()); + } + match started { + Ok(run) => { + let label = if task.browse { "Reading folder" } else { "Importing" }; + task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); + task.run = Some(run); + } + Err(e) => { + log::warn!("import: {e}"); + app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); + return; + } + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let Some(run) = task.run.as_mut() else { return }; + let batches = run.next_batches(); + let finished = batches.is_none(); + if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) { + task.moved_on = true; + } + for prepared in batches.into_iter().flatten() { + commit_batch(app, &mut task, prepared); + // arriving photos don't take over the selection + if let Some(sel) = &task.keep_selection { + app.session.selection = sel.clone(); + } + // a Local folder still on screen: its first photo becomes active once there is one (#1447) + if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { + app.session.keep_active_in_view(); + } + } + task.left = Some((app.session.selection.clone(), app.session.source)); + if let Some(run) = &task.run { + task.total = run.total.load(Ordering::Relaxed).max(task.total); + task.done = run.done.load(Ordering::Relaxed); + } + if let Some(guard) = &task.guard { + guard.progress(task.done as u64, task.total as u64); + // ✕ in the stack (or `activity.cancel`): no further files are started + if guard.is_cancelled() { + task.cancelled = true; + } + } + if !finished { + app.import = Some(task); + return; + } + finish(app, ctx, task); +} + +/// Add one readied batch to the catalog (an undo step merged into the import's at the end). +fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) { + let Some(run) = task.run.as_ref() else { return }; + let n = prepared.len(); + let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone()); + let r = app.session.execute_fn("library.import", |s| { + let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?; + lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref()) + }); + match r { + Ok(v) => { + // a new album (named in the params) is created with the first photos; later batches join it + if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) { + run.album = Some(a); + } + let len = |k: &str| v[k].as_array().map_or(0, Vec::len); + task.imported += len("imported"); + task.restored += len("restored"); + task.duplicates += len("duplicates"); + task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); + task.failed += len("failed"); + task.moved += len("moved"); + task.kept += len("kept"); + task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok())); + for k in v["kept"].as_array().into_iter().flatten() { + log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or("")); + } + if task.first.is_none() { + task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64)); + } + } + Err(e) => { + log::warn!("import: {e}"); + task.failed += n; + } + } +} + +fn xmp_warning_text(warning: &ImportWarning) -> String { + let path = &warning.path; + match warning.code { + ImportWarningCode::CameraProfile => crate::i18n::tr_format!( + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.", + profile = warning.profile.as_deref().unwrap_or_default(), + path = path, + ), + ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) { + ImportUprightMode::Off => crate::i18n::tr_format!( + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.", + path = path, + ), + ImportUprightMode::Guided => crate::i18n::tr_format!( + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.", + path = path, + ), + ImportUprightMode::Auto => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Auto") + ), + ImportUprightMode::Level => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Level") + ), + ImportUprightMode::Vertical => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Vertical") + ), + ImportUprightMode::Full => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Full") + ), + ImportUprightMode::Unknown => { + crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,) + } + }, + ImportWarningCode::UnmappedXmp => { + crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),) + } + } +} + +fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { + crate::i18n::tr_format!( + "Import warnings ({n}):\n{warnings}", + n = warnings.len(), + warnings = warnings.iter().map(xmp_warning_text).collect::>().join("\n"), + ) +} + +/// 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" })); + } + return; + } + if let Some(f) = task.first.filter(|_| !task.moved_on) { + // a view the new photos aren't in (an album, Picks, a Local folder) would leave the active + // photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446) + if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) { + let _ = app.run("library.source", json!({"kind": "all"})); + } + 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 + && task.restored == 0 + && task.failed == 0 + && task.warnings.is_empty() + && !task.cancelled + && show_existing(app, ctx, &task.existing) + { + 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)) + } else { + crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) + }; + if task.restored > 0 && (task.imported > 0 || task.cancelled) { + msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); + } + 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))); + } + } + if task.duplicates > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + let deleted = deleted_of(app, &task.existing).len(); + if deleted > 0 { + msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); + } + } + 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); + } +} + +/// The photos among `ids` that are in Recently Deleted (each once). +fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { + let mut v: Vec = + ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel +/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. +fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { + let deleted = deleted_of(app, existing); + let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; + ids.sort_unstable(); + ids.dedup(); + ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); + if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { + return false; + } + let _ = app.run("library.select", json!({"ids": ids})); + app.ui.left_panel = true; + let n = ids.len(); + let plural = if n == 1 { "" } else { "s" }; + let msg = if kind == "all" { + crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) + } else { + crate::i18n::tr_format!( + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", + n, + plural + ) + }; + app.toast_for(ctx, msg, 6.0); + true +} diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/dialogs/keymap.rs similarity index 88% rename from crates/ui-egui/src/panels/keymap.rs rename to crates/ui-egui/src/dialogs/keymap.rs index 1bda17758..3ebe25057 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/dialogs/keymap.rs @@ -15,21 +15,24 @@ use crate::widgets::register; const SEARCH: &str = "shortcuts-search"; const SHOW_ALL: &str = "shortcuts-show-all"; +/// Draw the Keyboard Shortcuts editor: search and "show all", Reset All, one row per bindable +/// command that has a shortcut (all bindable commands when "Show commands without a shortcut" is +/// on), and the fixed non-command keys. Pending shortcut capture is processed once per frame. pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; capture(app, ui.ctx()); ui.set_min_width(560.0); - let (search_id, all_id) = (egui::Id::new(SEARCH), egui::Id::new(SHOW_ALL)); + let (search_id, all_id) = (egui::Id::unique(SEARCH), egui::Id::unique(SHOW_ALL)); let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); let mut show_all: bool = ui.data(|d| d.get_temp(all_id)).unwrap_or(false); ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(&mut search).hint_text(crate::i18n::tr("Search commands or keys")).desired_width(220.0)); - register(ui.ctx(), "field:shortcutsSearch", r.rect); + register(ui, "field:shortcutsSearch", r.rect); let r = ui.checkbox(&mut show_all, crate::i18n::tr("Show commands without a shortcut")); - register(ui.ctx(), "check:shortcuts.showAll", r.rect); + register(ui, "check:shortcuts.showAll", r.rect); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let r = ui.add_enabled(!app.ui.settings.keymap.is_empty(), egui::Button::new(crate::i18n::tr("Reset All"))); - register(ui.ctx(), "button:shortcutsResetAll", r.rect); + register(ui, "button:shortcutsResetAll", r.rect); if r.clicked() { let _ = app.run("app.resetShortcuts", serde_json::json!({})); app.recording_shortcut = None; @@ -79,8 +82,12 @@ fn menu_text(sc: &str, mac: bool) -> String { crate::menubar::shortcut_text(sc, mac) } +/// One editable command row: its label and id, a click-to-record shortcut button, and buttons +/// that clear or restore it. The restore button is enabled when a saved binding exists or the +/// declared default is currently yielded to another command. fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) { - let changed = app.ui.settings.keymap.contains_key(b.id); + // a declared key that yields to another command's saved key counts as changed: ↺ takes it back + let changed = app.ui.settings.keymap.contains_key(b.id) || (sc.is_none() && b.default.is_some()); let mut name = RichText::new(crate::i18n::tr(b.label)).color(t.text); if changed { name = name.strong(); @@ -102,14 +109,14 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: } }; let r = ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0)).selected(recording)); - register(ui.ctx(), format!("button:shortcut-{}", b.id), r.rect); + register(ui, format!("button:shortcut-{}", b.id), r.rect); if r.clicked() { app.recording_shortcut = if recording { None } else { Some(b.id.to_string()) }; } ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let r = ui.add_enabled(sc.is_some(), egui::Button::new("×").small()).on_hover_text(crate::i18n::tr("Remove shortcut")); - register(ui.ctx(), format!("button:shortcutClear-{}", b.id), r.rect); + register(ui, format!("button:shortcutClear-{}", b.id), r.rect); if r.clicked() { apply(app, ui.ctx(), serde_json::json!({"id": b.id, "shortcut": null})); } @@ -117,7 +124,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Some(d) => crate::i18n::tr_format!("Restore {}", menu_text(d, mac)), None => crate::i18n::tr("Restore (no shortcut)").to_string(), }); - register(ui.ctx(), format!("button:shortcutReset-{}", b.id), r.rect); + register(ui, format!("button:shortcutReset-{}", b.id), r.rect); if r.clicked() { apply(app, ui.ctx(), serde_json::json!({"id": b.id, "reset": true})); } @@ -171,7 +178,8 @@ fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, nee } ui.add_space(8.0); ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text)); - let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect(); + // keys saved for commands this build knows: an entry for an unknown command takes no key + let taken: Vec<_> = shortcuts::saved_keys(&app.ui.settings.keymap).map(|(_, k)| k).collect(); egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { for (sc, label) in fixed { ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label)); diff --git a/crates/ui-egui/src/lightroom_destination.rs b/crates/ui-egui/src/dialogs/lightroom_destination.rs similarity index 98% rename from crates/ui-egui/src/lightroom_destination.rs rename to crates/ui-egui/src/dialogs/lightroom_destination.rs index a62c59825..5fd781cfd 100644 --- a/crates/ui-egui/src/lightroom_destination.rs +++ b/crates/ui-egui/src/dialogs/lightroom_destination.rs @@ -235,7 +235,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) { count = n ); let r = ui.radio_value(new_library, false, merge); - crate::widgets::register(ui.ctx(), "radio:lightroomDestination-open", r.rect); + crate::widgets::register(ui, "radio:lightroomDestination-open", r.rect); let indent = ui.spacing().icon_width + ui.spacing().icon_spacing; let note = |ui: &mut egui::Ui, text: &str| { ui.horizontal(|ui| { @@ -252,7 +252,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) { }, ); let r = ui.radio_value(new_library, true, tr("Create a new library")); - crate::widgets::register(ui.ctx(), "radio:lightroomDestination-new", r.rect); + crate::widgets::register(ui, "radio:lightroomDestination-new", r.rect); ui.add_enabled_ui(*new_library, |ui| { // a field's caption, left-aligned in a column as wide as the longer one (in any language) let font = egui::TextStyle::Body.resolve(ui.style()); @@ -298,7 +298,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) { // (it only speaks of merging: unticked and greyed out while a new library is chosen) let mut ticked = *dont_ask && !*new_library; let c = ui.add_enabled(!*new_library, egui::Checkbox::new(&mut ticked, tr("Don't ask again (always merge into the open library)"))); - crate::widgets::register(ui.ctx(), "check:lightroomDontAsk", c.rect); + crate::widgets::register(ui, "check:lightroomDontAsk", c.rect); if !*new_library { *dont_ask = ticked; } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/dialogs/merge.rs similarity index 99% rename from crates/ui-egui/src/merge.rs rename to crates/ui-egui/src/dialogs/merge.rs index 0ecb5f765..c9303b498 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/dialogs/merge.rs @@ -433,7 +433,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) let box_size = egui::vec2(520.0, 360.0); let (rect, resp) = ui.allocate_exact_size(box_size, egui::Sense::click_and_drag()); ui.painter().rect_filled(rect, 4.0, t.canvas); - crate::widgets::register(ui.ctx(), "merge.preview", rect); + crate::widgets::register(ui, "merge.preview", rect); if let Some((tex, size, _, _)) = &app.merge.preview { let (tex, size) = (tex.id(), *size); let (r, zoom) = preview_rect(rect, size, ui.ctx().pixels_per_point(), app.merge.zoom); diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/dialogs/print_view.rs similarity index 93% rename from crates/ui-egui/src/print_view.rs rename to crates/ui-egui/src/dialogs/print_view.rs index d1c94f142..82a2b564d 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/dialogs/print_view.rs @@ -105,14 +105,14 @@ fn length(ui: &mut Ui, id: &str, inches: f64, unit: Unit, min_in: f64, max_in: f .max_decimals(decimals(unit)) .suffix(format!(" {}", unit.label())), ); - register(ui.ctx(), format!("drag:{id}"), r.rect); + register(ui, format!("drag:{id}"), r.rect); r.changed().then(|| unit.to_inches(v)) } fn check(ui: &mut Ui, id: &str, label: &str, value: bool) -> Option { let mut v = value; let r = ui.checkbox(&mut v, crate::i18n::tr(label)); - register(ui.ctx(), format!("check:{id}"), r.rect); + register(ui, format!("check:{id}"), r.rect); r.changed().then_some(v) } @@ -130,7 +130,7 @@ fn choice(ui: &mut Ui, id: &str, options: &[(V, &str)], cur fn colour(ui: &mut Ui, id: &str, rgb: [u8; 3]) -> Option<[u8; 3]> { let mut c = rgb; let r = ui.color_edit_button_srgb(&mut c); - register(ui.ctx(), format!("color:{id}"), r.rect); + register(ui, format!("color:{id}"), r.rect); (c != rgb).then_some(c) } @@ -212,6 +212,14 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); } + if app.services.pick_print_templates.is_some() && text_button(ui, "printTemplateImport", "Import…", false).clicked() { + let paths = app.services.pick_print_templates.as_mut().map(|pick| pick()).unwrap_or_default(); + for path in paths { + if let Some(r) = run(app, &ctx, "print.template.import", json!({"path": path})) { + app.toast(&ctx, crate::i18n::tr_format!("Imported {name}", name = r["name"].as_str().unwrap_or_default())); + } + } + } }); let names: Vec = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); for (i, name) in names.iter().enumerate() { @@ -221,6 +229,15 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { run(app, &ctx, "print.template.delete", json!({"name": name})); } + if app.services.save_print_template.is_some() && text_button(ui, &format!("printTemplateExport-{i}"), "Export…", false).clicked() { + let stem = lightcraft_engine::slide_template::file_stem(name).unwrap_or_else(|| "Print Template".into()); + let file = format!("{stem}.{}", lightcraft_engine::print::template::FILE_EXT); + if let Some(path) = app.services.save_print_template.as_mut().and_then(|save| save(&file)) + && let Some(r) = run(app, &ctx, "print.template.export", json!({"name": name, "path": path})) + { + app.toast(&ctx, crate::i18n::tr_format!("Exported to {path}", path = r["path"].as_str().unwrap_or_default())); + } + } if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { run(app, &ctx, "print.template.apply", json!({"name": name})); pu.page = 0; @@ -267,7 +284,7 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, } let mut pt = im.stroke_width_pt; let r = ui.add(egui::DragValue::new(&mut pt).range(0.0..=36.0).speed(0.05).max_decimals(2).suffix(" pt")); - register(ui.ctx(), "drag:printStrokeWidth", r.rect); + register(ui, "drag:printStrokeWidth", r.rect); if r.changed() { patch(app, &ctx, json!({"image": {"strokeWidthPt": pt}})); } @@ -286,9 +303,9 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, row(ui, t, "Grid", |ui| { let (mut c, mut r) = (g.columns, g.rows); let a = ui.add(egui::DragValue::new(&mut c).range(1..=layout::MAX_GRID).suffix(" cols")); - register(ui.ctx(), "drag:printColumns", a.rect); + register(ui, "drag:printColumns", a.rect); let b = ui.add(egui::DragValue::new(&mut r).range(1..=layout::MAX_GRID).suffix(" rows")); - register(ui.ctx(), "drag:printRows", b.rect); + register(ui, "drag:printRows", b.rect); if a.changed() || b.changed() { patch(app, &ctx, json!({"layout": {"grid": {"columns": c, "rows": r}}})); } @@ -390,14 +407,15 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, heading(ui, t, "Page Setup"); row(ui, t, "Paper", |ui| { let current = current_paper(&l.page.paper); - let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(current)).show_ui(ui, |ui| { + let c = craft_ui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(current)).show_ui(ui, |ui| { for name in layout::paper_names().chain(std::iter::once("custom")) { if ui.selectable_label(current == name, paper_label(name)).clicked() { patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); } } }); - register(ui.ctx(), "combo:printPaper", c.response.rect); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper")); + register(ui, "combo:printPaper", c.response.rect); }); if l.page.paper.eq_ignore_ascii_case("custom") { row(ui, t, "Size", |ui| { @@ -453,7 +471,7 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, row(ui, t, "Resolution", |ui| { let mut ppi = st.job.ppi; let r = ui.add(egui::DragValue::new(&mut ppi).range(72.0..=720.0).speed(1.0).max_decimals(0).suffix(" ppi")); - register(ui.ctx(), "drag:printPpi", r.rect); + register(ui, "drag:printPpi", r.rect); if r.changed() { patch(app, &ctx, json!({"job": {"ppi": ppi}})); } @@ -481,10 +499,10 @@ fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &Pri }; let mut pick = false; row(ui, t, "Profile", |ui| { - let c = egui::ComboBox::from_id_salt("print-profile").width(190.0).selected_text(shown.clone()).show_ui(ui, |ui| { + let c = craft_ui::ComboBox::from_id_salt("print-profile").width(190.0).selected_text(shown.clone()).show_ui(ui, |ui| { for (space, label) in [(ManagedSpace::Srgb, "Managed by Printer (sRGB)"), (ManagedSpace::AdobeRgb, "Managed by Printer (Adobe RGB)")] { let r = ui.selectable_label(color.profile == managed(space), crate::i18n::tr(label)); - register(ui.ctx(), format!("item:printProfile-{}", to_value(space).as_str().unwrap_or_default()), r.rect); + register(ui, format!("item:printProfile-{}", to_value(space).as_str().unwrap_or_default()), r.rect); if r.clicked() && color.profile != managed(space) { patch_color(app, &ctx, "profile", to_value(managed(space))); } @@ -494,11 +512,12 @@ fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &Pri } if app.services.pick_display_profile.is_some() { let r = ui.selectable_label(false, crate::i18n::tr("Other…")); - register(ui.ctx(), "item:printProfile-other", r.rect); + register(ui, "item:printProfile-other", r.rect); pick |= r.clicked(); } }); - register(ui.ctx(), "combo:printProfile", c.response.rect); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Profile")); + register(ui, "combo:printProfile", c.response.rect); }); if pick && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) { patch_color(app, &ctx, "profile", json!({"kind": "icc", "path": path})); @@ -517,7 +536,7 @@ fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &Pri row(ui, t, label, |ui| { let mut v = value; let r = ui.add(egui::DragValue::new(&mut v).range(-100.0..=100.0).speed(0.5).max_decimals(0)); - register(ui.ctx(), format!("drag:print{label}"), r.rect); + register(ui, format!("drag:print{label}"), r.rect); if r.changed() { patch_color(app, &ctx, key, json!(v)); } @@ -572,7 +591,6 @@ impl Sheet { } fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, ids: &[PhotoId], pages: usize) { - let ctx = ui.ctx().clone(); // room for the page buttons and, for a package, the selected cell's row (which may wrap) let footer = if st.layout.style.is_package() { 104.0 } else { 44.0 }; let size = vec2(ui.available_width(), (ui.available_height() - footer).max(120.0)); @@ -588,7 +606,7 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s let inner = Rect::from_min_max(area.min + vec2(ruler, ruler), area.max).shrink(14.0); let scale = ((inner.width() as f64 / pw).min(inner.height() as f64 / ph) as f32).max(1.0); let sheet = Sheet { page: Rect::from_center_size(inner.center(), vec2(pw as f32, ph as f32) * scale), scale }; - register(&ctx, "print:page", sheet.page); + register(ui, "print:page", sheet.page); painter.rect_filled(sheet.page.translate(vec2(2.0, 3.0)), 0.0, Color32::from_black_alpha(70)); let bg = st.page.background; @@ -638,7 +656,7 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s let mut shown = Vec::new(); for (i, (r, photo)) in cells.iter().enumerate() { let rect = sheet.rect(r); - register(&ctx, format!("print:gridcell:{i}"), rect); + register(ui, format!("print:gridcell:{i}"), rect); shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r)); } page_options(&painter, st, &sheet, &shown, pu.page + 1); @@ -651,7 +669,7 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s pu.selected = None; } let label = ui.label(egui::RichText::new(format!("{} {} / {}", crate::i18n::tr("Page"), pu.page + 1, pages)).color(t.text)); - register(ui.ctx(), "print:pageLabel", label.rect); + register(ui, "print:pageLabel", label.rect); if text_button(ui, "printPageNext", "▶", false).clicked() && pu.page + 1 < pages { pu.page += 1; pu.selected = None; @@ -692,8 +710,8 @@ fn package_cells( let photo = if st.layout.style == Style::CustomPackage { c.photo.map(PhotoId).or(first) } else { first }; let rect = sheet.rect(&c.rect); paint_cell(app, painter, t, st, rect, photo, sheet, &c.rect); - let resp = ui.interact(rect, egui::Id::new(("print-cell", c.id)), Sense::click_and_drag()); - register(&ctx, format!("print:cell:{}", c.id), rect); + let resp = ui.interact(rect, egui::Id::unique(("print-cell", c.id)), Sense::click_and_drag()); + register(ui, format!("print:cell:{}", c.id), rect); if resp.clicked() || resp.drag_started() { pu.selected = Some(c.id); } @@ -734,8 +752,8 @@ fn package_cells( painter.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); let handle = Rect::from_center_size(rect.right_bottom(), Vec2::splat(HANDLE)); painter.rect_filled(handle, 2.0, t.accent); - let resp = ui.interact(handle, egui::Id::new(("print-resize", sel.id)), Sense::drag()); - register(&ctx, format!("print:resize:{}", sel.id), handle); + let resp = ui.interact(handle, egui::Id::unique(("print-resize", sel.id)), Sense::drag()); + register(ui, format!("print:resize:{}", sel.id), handle); if resp.drag_started() { pu.drag = Some(Drag { cell: sel.id, origin: sel.rect, resize: true, press: press_origin(ui, &resp), total: Vec2::ZERO }); } diff --git a/crates/ui-egui/src/dialogs/rename.rs b/crates/ui-egui/src/dialogs/rename.rs new file mode 100644 index 000000000..f178ba6eb --- /dev/null +++ b/crates/ui-egui/src/dialogs/rename.rs @@ -0,0 +1,96 @@ +use super::*; + +/// Rows the Rename dialog previews. +pub(super) const RENAME_PREVIEW_ROWS: usize = 6; + +/// The Rename dialog's preview: its first rows (`None` while being planned) and how many files +/// are renamed. Which names are taken is checked on disk, which can block on a slow drive, so the +/// rows are planned on a worker thread, and only when the template, start number, photos or +/// catalog change — never once per frame. +pub(crate) fn rename_preview( + app: &mut LightcraftApp, + ctx: &egui::Context, + template: &str, + start: usize, +) -> (Option>, usize) { + use std::hash::{Hash, Hasher}; + type Rows = std::sync::Arc>>>; + let ids = app.session.selected(); + let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); + let total = photos.len(); + let mut h = std::collections::hash_map::DefaultHasher::new(); + (template, start, app.session.catalog.revision, &ids).hash(&mut h); + let key = h.finish(); + let id = egui::Id::unique("rename-preview-rows"); + let read = |rows: &Rows| rows.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(); + if let Some((k, rows)) = ctx.data(|d| d.get_temp::<(u64, Rows)>(id)) + && k == key + { + return (read(&rows), total); + } + let rows: Rows = Default::default(); + let first: Vec<_> = photos.into_iter().take(RENAME_PREVIEW_ROWS).collect(); + let (out, template, repaint) = (rows.clone(), template.to_string(), ctx.clone()); + let exists = app.session.media.availability.probe(); + let work = move || { + let plans = lightcraft_engine::rename::plan_rename_photos(&first, &template, start, &|f| exists(f)); + *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(plans); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-rename-preview".into()).spawn(work) { + log::warn!("rename preview: {e}"); + } + #[cfg(target_arch = "wasm32")] + work(); + ctx.data_mut(|d| d.insert_temp(id, (key, rows.clone()))); + (read(&rows), total) +} + +pub(super) struct PersonRenamePlan { + pub(super) faces: usize, + pub(super) needs_merge: bool, + /// Why Rename stays disabled. `None` when the typed name can be applied. + pub(super) block: Option<&'static str>, +} + +/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`. +pub(super) fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan { + let to_trim = to.trim(); + if to_trim.is_empty() { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") }; + } + if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") }; + } + let from_key = from.trim().to_lowercase(); + let to_key = to_trim.to_lowercase(); + let mut matched = 0usize; + let mut faces = 0usize; + let mut needs_merge = false; + if !from_key.is_empty() { + for photo in cat.photos().filter(|p| p.in_library()) { + for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + if key == from_key { + matched += 1; + if region.name.as_deref() != Some(to_trim) { + faces += 1; + } + } else if key == to_key { + needs_merge = true; + } + } + } + } + let block = if matched == 0 { + Some("No person by that name") + } else if faces == 0 { + Some("That is already their name") + } else { + None + }; + // a case-only change is the same person, not a merge + PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } +} diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/dialogs/rules_editor.rs similarity index 87% rename from crates/ui-egui/src/panels/rules_editor.rs rename to crates/ui-egui/src/dialogs/rules_editor.rs index d69a8fa43..67f5e4bb4 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/dialogs/rules_editor.rs @@ -11,6 +11,8 @@ use crate::date_picker::{DatePicker, PickedDate}; use crate::theme::Tokens; use crate::widgets::register; +pub mod rules_move; + /// A sensible starting value for `field` / `op`. pub fn default_value(field: &str, op: &str) -> Value { match (field_kind(field), op) { @@ -51,11 +53,12 @@ fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) { Match::Any => "any", Match::None => "none", }; - crate::widgets::combo_box(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { + let combo = crate::widgets::combo_box(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { for x in [Match::All, Match::Any, Match::None] { ui.selectable_value(m, x, crate::i18n::tr(label(x))); } }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Match")); } fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &str) { @@ -65,7 +68,7 @@ fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &s other => other.to_string(), }; let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(crate::i18n::tr(hint)).desired_width(width)); - register(ui.ctx(), format!("field:{salt}"), r.rect); + register(ui, format!("field:{salt}"), r.rect); if r.changed() { *v = json!(s); } @@ -149,7 +152,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & let mut unit = stored_unit.to_lowercase(); let mut changed = ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).clamp_existing_to_range(false).speed(0.2).fixed_decimals(0)).changed(); - crate::widgets::combo_box(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { + let combo = crate::widgets::combo_box(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { for u in ["hours", "days", "weeks", "months", "years"] { if ui.selectable_label(unit.trim_end_matches('s') == u.trim_end_matches('s'), crate::i18n::tr(u)).clicked() { unit = u.to_string(); @@ -157,6 +160,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & } } }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Unit")); if changed { *v = json!({"n": n.round(), "unit": unit}); } @@ -198,26 +202,31 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & (Some(Kind::Number), _) => number_value(ui, v, field), (Some(Kind::Choice(c)), _) => { let cur = v.as_str().unwrap_or("").to_string(); - crate::widgets::combo_box(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui(ui, |ui| { - for (id, _) in c.iter() { - if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { - *v = json!(id); + let combo = crate::widgets::combo_box(format!("{salt}-choice")) + .width(120.0) + .selected_text(crate::i18n::choice_text(field, &cur)) + .show_ui(ui, |ui| { + for (id, _) in c.iter() { + if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { + *v = json!(id); + } } - } - }); + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr(field_label(field).unwrap_or(field))); } (Some(Kind::Bool), _) => { // the catalog's reading of the value ("false" is no); one that is neither yes nor no is // shown as written and kept until a choice replaces it, so the dialog reports it let cur = bool_value(v); let text = cur.map_or_else(|| v.to_string(), |b| crate::i18n::bool_text(b).to_string()); - crate::widgets::combo_box(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { + let combo = crate::widgets::combo_box(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { for b in [true, false] { if ui.selectable_label(cur == Some(b), crate::i18n::bool_text(b)).clicked() { *v = json!(b); } } }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr(field_label(field).unwrap_or(field))); } (Some(Kind::Date), _) => { text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt); @@ -236,7 +245,7 @@ fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String let mut pick = None; let mut item = |ui: &mut egui::Ui, id: &'static str| { let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); - register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); + register(ui, format!("ruleFieldItem:{id}:{salt}"), r.rect); if r.clicked() { pick = Some(id); ui.close(); @@ -258,11 +267,12 @@ fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String item(ui, id); } }); - register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); + register(ui, format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); } pick }); - register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect); + crate::widgets::name_combo(&r.response, crate::i18n::tr("Field")); + register(ui, format!("ruleField:{salt}"), r.response.rect); let Some(Some(id)) = r.inner else { return }; if field == id { return; @@ -287,7 +297,7 @@ fn problem_note(ui: &mut egui::Ui, problem: Option<&Problem>, salt: &str) { let Some(p) = problem else { return }; let t = Tokens::get(ui.ctx()); let r = ui.label(RichText::new(format!("⚠ {}", crate::i18n::problem_text(p))).color(t.caution).small()); - register(ui.ctx(), format!("ruleProblem:{salt}"), r.rect); + register(ui, format!("ruleProblem:{salt}"), r.rect); } /// Edit `rs` in place; `salt` keeps widget ids apart between groups, `path` is where `rs` sits @@ -322,17 +332,19 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: field_menu(ui, &rsalt, field, op, value); let kind = field_kind(field).unwrap_or(Kind::Text); let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string(); - crate::widgets::combo_box(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { - for (id, l) in ops_for(kind) { - if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id { - let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty"); - *op = id.to_string(); - if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") { - *value = default_value(field, op); + let combo = + crate::widgets::combo_box(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { + for (id, l) in ops_for(kind) { + if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id { + let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty"); + *op = id.to_string(); + if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") { + *value = default_value(field, op); + } } } - } - }); + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Operator")); value_editor(ui, field, op, value, &rsalt, env); // − and + at the end of the row, after the value however long it is: a // right-to-left layout put them over the value when the row had no room left @@ -340,12 +352,12 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: let buttons = small_button_width(ui, "−") + ui.spacing().item_spacing.x + small_button_width(ui, "+"); ui.add_space((ui.available_width() - buttons).max(0.0)); let del = ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this rule")); - register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect); + register(ui, format!("button:ruleRemove-{rsalt}"), del.rect); if del.clicked() { remove = Some(i); } let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)")); - register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect); + register(ui, format!("button:ruleAdd-{rsalt}"), add.rect); if add.clicked() { let group = ui.input(|i| i.modifiers.alt) && depth < 3; insert = Some(( @@ -367,7 +379,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: if rs.rules.is_empty() || depth == 0 { ui.horizontal(|ui| { let add = ui.small_button(crate::i18n::tr("+ Rule")); - register(ui.ctx(), format!("button:ruleAddEnd-{salt}"), add.rect); + register(ui, format!("button:ruleAddEnd-{salt}"), add.rect); if add.clicked() { rs.rules.push(new_rule()); } diff --git a/crates/ui-egui/src/panels/rules_move.rs b/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs similarity index 100% rename from crates/ui-egui/src/panels/rules_move.rs rename to crates/ui-egui/src/dialogs/rules_editor/rules_move.rs diff --git a/crates/ui-egui/src/dialogs/sam_model.rs b/crates/ui-egui/src/dialogs/sam_model.rs new file mode 100644 index 000000000..fa578440b --- /dev/null +++ b/crates/ui-egui/src/dialogs/sam_model.rs @@ -0,0 +1,114 @@ +use super::*; + +/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is +/// all the dialog can offer. +pub(super) fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool { + !sam.installed() && !sam.download_status().running && sam.mirrors().is_empty() +} + +/// The model installation guide. +const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model"; + +/// Open the configured SAM 3 folder itself; revealing a file opens its parent instead. +pub(crate) fn show_model_folder(app: &mut LightcraftApp) -> Result { + let dir = app.session.segmenter.dir.as_ref().ok_or("no folder is set for the SAM 3 model")?; + let path = dir.to_str().ok_or("the model folder path is not valid UTF-8")?; + let open = app.services.open_with.as_mut().ok_or("opening model folders is not available here")?; + // Create only after the host action is available. Existing model files are left alone. + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + #[cfg(target_os = "linux")] + if let Some(open_folder) = app.services.open_model_folder.clone() { + let path = path.to_string(); + let folder = path.clone(); + crate::tasks::spawn( + app, + "Show in File Manager", + Some("openModelFolder"), + move || open_folder(&folder), + |app, ctx, result| { + if let Err(e) = result { + app.toast_error(ctx, e); + } + }, + )?; + return Ok(json!({"path": path, "background": true})); + } + open(path, "")?; + Ok(json!({"path": path})) +} + +/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress. +pub(super) fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) { + use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES}; + let t = Tokens::get(ui.ctx()); + let seg = &app.session.segmenter; + let d = seg.download_status(); + if seg.installed() { + ui.label(crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.")); + return; + } + let gb = |b: u64| b as f64 / 1e9; + ui.label(crate::i18n::tr( + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.", + )); + let dir = seg.dir.as_ref().map(|d| d.display().to_string()).unwrap_or_default(); + ui.label(format!("{} {:.1} GB, {} {dir}", crate::i18n::tr("A one-time download of about"), gb(MODEL_BYTES), crate::i18n::tr("saved in"))); + ui.label( + egui::RichText::new(format!( + "{} {LICENSE_NAME} — {}", + crate::i18n::tr("Licence:"), + crate::i18n::tr("Meta's terms, not LightCraft's. Downloading it means accepting them.") + )) + .color(t.text_label), + ); + let r = ui.link(crate::i18n::tr("Read the SAM License")).on_hover_text(LICENSE_URL); + crate::widgets::register(ui, "link:samLicense", r.rect); + if r.clicked() { + let _ = crate::links::open(app, LICENSE_URL); + } + let seg = &app.session.segmenter; + if d.running { + ui.add_space(4.0); + let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file); + crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui); + let r = ui.button(crate::i18n::tr("Cancel Download")); + crate::widgets::register(ui, "button:samCancel", r.rect); + if r.clicked() { + app.session.segmenter.cancel_download(); + } + ui.label(egui::RichText::new(crate::i18n::tr("You can close this: the download continues, and resumes if interrupted.")).color(t.text_dim)); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); + return; + } + if seg.mirrors().is_empty() { + // nothing to download from: say how to install it by hand, with the folder and the guide a click away + let dir = seg.dir.clone(); + ui.label( + egui::RichText::new(crate::i18n::tr( + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.", + )) + .color(t.text_dim), + ); + ui.horizontal_wrapped(|ui| { + if dir.is_some() && crate::menus::ui_enabled(app, "app.openSamModelFolder") { + let r = ui.button(crate::i18n::tr(crate::menus::reveal_label())); + crate::widgets::register(ui, "button:samFolder", r.rect); + if r.clicked() + && let Err(e) = app.run("app.openSamModelFolder", json!({})) + { + app.toast_error(ui.ctx(), e); + } + } + let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP); + crate::widgets::register(ui, "link:samHelp", r.rect); + if r.clicked() + && let Err(e) = crate::links::open(app, SAM_HELP) + { + app.toast(ui.ctx(), e); + } + }); + } + if let Some(e) = error.map(str::to_string).or(d.error) { + ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80))); + } +} diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs new file mode 100644 index 000000000..7b264c811 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -0,0 +1,162 @@ +//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Slideshow, Faces, +//! AI Denoise. +//! +//! Changes apply immediately (no OK/Cancel). Where they are stored: +//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, +//! preview size, filmstrip/grid badges, last library, monitor profile, whether Import Lightroom +//! Catalog… asks where a catalog goes) and Auto Advance live in +//! the UI state, +//! saved by the host in its config folder (`ui.json`); +//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow +//! playback) go through the `library.preferences` / `library.xmpPreferences` / +//! `slideshow.playback` commands into the library's `prefs.json`, +//! so they travel with the library. + +use egui::RichText; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::{GridBadges, PREVIEW_LIMITS, StartupView}; +use crate::theme::Tokens; +use crate::widgets::register; + +mod appearance; +mod display; +mod general; +mod import; +mod libraries; +mod performance; +mod slideshow; +mod widgets; + +pub use display::display_profile; +pub(super) use display::view_options; +use display::{display_tab, interface_tab}; +use general::general_tab; +use import::import_tab; +pub(crate) use libraries::{can_switch_library, free_name}; +pub use libraries::{forget_library, library_name, new_library, open_library, open_library_name, recent_libraries, remember_library}; +use performance::performance_tab; +use slideshow::slideshow_tab; +pub(crate) use widgets::{check, choices, heading, hint, row}; + +pub mod slide_layout; +pub mod slide_overlays; +pub mod slide_templates; +pub mod slide_titles; + +/// (id, label) of the tabs, in order. +pub const TABS: &[(&str, &str)] = &[ + ("general", "General"), + ("import", "Import"), + ("performance", "Performance"), + ("display", "Display"), + ("interface", "Interface"), + ("slideshow", "Slideshow"), + ("faces", "Faces"), + ("denoise", "AI Denoise"), +]; + +/// Thumbnail cache sizes offered (MB). +const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192]; + +const LABEL_W: f32 = 150.0; + +/// The page of `tab` (the tab bar in [`window`] switches `tab`). +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) { + let t = Tokens::get(ui.ctx()); + match tab { + "import" => import_tab(app, ui, &t), + "performance" => performance_tab(app, ui, &t), + "display" => display_tab(app, ui, &t), + "interface" => interface_tab(app, ui, &t), + "slideshow" => slideshow_tab(app, ui, &t, true), + "faces" => crate::panels::faces::settings_tab(app, ui, &t), + "denoise" => crate::panels::denoise::settings_tab(app, ui, &t), + _ => general_tab(app, ui, &t), + } +} + +/// The widest the Settings window's content gets, and the tallest its pages get: the window is +/// one size on every tab, so it stays where it is as the tabs are gone through. +const PAGE_SIZE: egui::Vec2 = egui::vec2(560.0, 640.0); + +/// The least room in which a page scrolls by itself. In a window with less (far below the +/// desktop minimum), the tabs and the page scroll together in the dialog's own scroll area. +const PAGE_MIN_HEIGHT: f32 = 120.0; + +/// The share of the page's room the pinned slide preview of the Slideshow tab takes, when the page +/// has more room than it needs to keep [`PAGE_MIN_HEIGHT`] for itself. +const PINNED_PREVIEW_SHARE: f32 = 0.4; + +/// What the preview's heading, the one-line hint under the slide, its Photo / Intro / Ending +/// choice and the rule under it take around the slide (at the default spacing, about 82 pt). +const PREVIEW_CHROME: f32 = 82.0; + +/// The Settings window's content: the tab bar, which stays put, above the page of `tab` +/// ([`body`]), which scrolls when it is taller than the room for it, with a scroll handle that +/// shows without the pointer on it. `max_height` is the room the app window leaves for both: the +/// two together never take more, so the scroll area every dialog is drawn in has nothing to +/// scroll (but for a window with less than [`PAGE_MIN_HEIGHT`] for the page, where that area +/// scrolls the two together). Every page gets the same room whatever it holds (but for the pinned +/// preview of Slideshow), and each keeps a scroll position of its own. Nothing here is wider than the window from 640 pt of app window +/// up: a row's controls wrap ([`row`]) and paths are shortened ([`path_label`]). +/// On Slideshow the slide preview is pinned between the tab bar and the page (where the room +/// allows), and comes off that page's room. +pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_height: f32) { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, PAGE_SIZE.x)); + let top = ui.cursor().top(); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); + for (id, label) in TABS { + if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { + *tab = id.to_string(); + } + } + }); + ui.separator(); + // (a point less than the room left, so rounding never makes the dialog's own scroll area scroll) + let room = max_height - (ui.cursor().top() - top) - 1.0; + if room < PAGE_MIN_HEIGHT { + body(app, ui, tab.as_str()); + register(ui, "settings:page", ui.min_rect()); + return; + } + let mut height = room.min(PAGE_SIZE.y); + // The slide preview stays above the page on Slideshow: hovering a template in the Template + // Browser previews it there, so it has to stay in view however far down the templates are + // scrolled to. With its heading and choice it takes [`PINNED_PREVIEW_SHARE`] of the room + // (at least the least tall slide); where that leaves less than [`PAGE_MIN_HEIGHT`] for the + // page, it scrolls with the page instead (as in the short window above). + let mut pinned = false; + if tab == "slideshow" { + let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(crate::panels::slide_layout::PREVIEW_MIN_H); + if height - (slide_h + PREVIEW_CHROME) >= PAGE_MIN_HEIGHT { + let before = ui.cursor().top(); + crate::panels::slide_layout::preview(app, ui, &Tokens::get(ui.ctx()), slide_h); + ui.add_space(2.0); + ui.separator(); + height = (height - (ui.cursor().top() - before)).max(PAGE_MIN_HEIGHT); + pinned = true; + } + } + // A page that goes on below shows its scroll handle all the time: without it, a page a line + // or two too long looks cut off, not scrollable. + ui.spacing_mut().scroll.dormant_handle_opacity = 0.6; + let page = egui::ScrollArea::vertical() + .id_salt(("settings-page", tab.as_str())) + .max_height(height) + .min_scrolled_height(height) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add_space(2.0); + if pinned { + slideshow_tab(app, ui, &Tokens::get(ui.ctx()), false); + } else { + body(app, ui, tab.as_str()); + } + // (what the page took: wider than the room for it would mean a control is cut off) + register(ui, "settings:page", ui.min_rect()); + }); + register(ui, "settings:pageRoom", page.inner_rect); +} diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs new file mode 100644 index 000000000..51159df6b --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -0,0 +1,220 @@ +use super::widgets::{choices, heading}; +use super::*; + +/// 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 +/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). +pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { + use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; + heading(ui, t, crate::i18n::tr("Appearance")); + ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); + }); + ui.add_space(4.0); + let light_active = s.theme(system) == s.light_theme.kind(); + let available = ui.available_width(); + 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); + 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); + }; + if available < 440.0 { + ui.vertical(|ui| cards(ui, available)); + } else { + ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); + } + ui.add_space(2.0); +} + +/// 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, +) { + egui::Frame::new() + .fill(t.inset) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.vertical(|ui| { + let inner = (width - 18.0).clamp(120.0, 240.0); + ui.set_width(inner); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); + if active { + ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); + } + }); + let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); + theme_preview(ui, shown, inner); + for (v, kind) in options { + let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); + register(ui, format!("radio:theme-{}", kind.id()), r.rect); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let favorite = favorites.iter().any(|id| id == kind.id()); + let r = ui + .add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false)) + .on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" })); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::Role::Button, + true, + format!( + "{}: {}", + crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }), + crate::i18n::tr(kind.label()) + ), + ) + }); + register(ui, format!("button:themeFavorite-{}", kind.id()), r.rect); + if favorite || hovered { + crate::rating::paint_star(ui.painter(), r.rect.shrink(4.0), favorite, if favorite { t.accent } else { t.text_dim }); + } + if r.clicked() { + if favorite { + favorites.retain(|id| id != kind.id()); + } else { + favorites.push(kind.id().into()); + } + } + }); + }); + } + }); + }); +} + +/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the +/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and +/// the bottom bar. +fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { + use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); + painter.rect_filled(rect, 3.0, p.canvas); + // top bar: sidebar and back icons, the centred search field, icons on the right + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(top, 0.0, p.chrome); + for x in [6.0, 14.0, 20.0] { + painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); + } + let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); + painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); + bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); + for i in 0..4 { + painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); + } + // bottom bar: view buttons, rating stars, a button + let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); + painter.rect_filled(bottom, 0.0, p.chrome); + for x in [6.0, 13.0, 20.0] { + painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); + } + let stars_x = bottom.center().x - 30.0; + for i in 0..5 { + painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); + } + let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); + painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); + // tool strip on the right edge, the first tool (Edit) active + let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); + painter.rect_filled(strip, 0.0, p.chrome); + painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); + if i == 0 { + painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); + } + painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); + } + // Edit panel: histogram, then labelled sliders + let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); + painter.rect_filled(panel, 0.0, p.chrome); + painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); + let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); + painter.rect_filled(hist, 1.0, p.inset); + let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; + let mut pts = vec![hist.left_bottom()]; + for (i, h) in curve.iter().enumerate() { + pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); + } + pts.push(hist.right_bottom()); + // the area under the curve, a column per stretch between two points + for pair in pts.windows(2) { + if let [a, b] = pair { + let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); + painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); + } + } + painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); + let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; + let mut y = hist.bottom() + 7.0; + bar(y, panel.left() + 5.0, 14.0, p.text); + y += 6.0; + for v in sliders { + if y + 7.0 > panel.bottom() { + break; + } + bar(y, panel.left() + 5.0, 12.0, p.text_label); + bar(y, panel.right() - 10.0, 5.0, p.text_dim); + let track_y = y + 5.0; + painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); + painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); + y += 10.0; + } + // the photo (its own colours: a photo looks the same in every theme) above the filmstrip + let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); + let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); + let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); + painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); + let horizon = photo.top() + photo.height() * 0.62; + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left(), horizon), + pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), + pos2(photo.left() + photo.width() * 0.55, horizon), + ], + Color32::from_rgb(0x4a, 0x55, 0x66), + Stroke::NONE, + )); + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left() + photo.width() * 0.35, horizon), + pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), + pos2(photo.right(), horizon), + ], + Color32::from_rgb(0x5a, 0x63, 0x72), + Stroke::NONE, + )); + painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); + painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); + if cell.right() > film.right() - 2.0 { + break; + } + let selected = i == 1; + painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); + let thumb = cell.shrink(2.5); + painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); + if selected { + painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); + } + } +} diff --git a/crates/ui-egui/src/dialogs/settings/display.rs b/crates/ui-egui/src/dialogs/settings/display.rs new file mode 100644 index 000000000..a54b05ced --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/display.rs @@ -0,0 +1,221 @@ +use super::appearance::appearance; +use super::widgets::{check, choices, heading, hint, path_label, row}; +use super::*; + +// ---------------------------------------------------------------------------------- Interface + +/// The monitor profile (`app.displayProfile`): previews are shown through the display's ICC +/// profile, so wide-gamut and calibrated displays show colours as they are. +pub(super) fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Monitor profile")); + let current = app.renderer.display().cloned(); + let screen = current.as_ref().is_some_and(|d| d.path == crate::SCREEN_PROFILE); + row(ui, t, crate::i18n::tr("Profile"), |ui| { + let name = match ¤t { + Some(d) => d.profile.description.clone(), + None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(), + }; + ui.label(RichText::new(name).color(t.text)); + if screen { + hint(ui, t, crate::i18n::tr("this screen's profile")); + } + }); + row(ui, t, "", |ui| { + let can = app.services.pick_display_profile.is_some(); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose Profile…"))); + register(ui, "button:settingsDisplayProfile", r.rect); + if r.clicked() + && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) + { + app.ui.settings.display_profile = path; + } + // with no profile chosen, previews follow the screen's own profile where the host knows it + let none = if app.services.screen_profile.is_some() { crate::i18n::tr("Use Screen Profile") } else { crate::i18n::tr("Use sRGB") }; + let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(none)); + register(ui, "button:settingsDisplayProfileNone", r.rect); + if r.clicked() { + app.ui.settings.display_profile.clear(); + } + if !can { + hint(ui, t, crate::i18n::tr("not available here")); + } + }); + if let Some(e) = &app.display_error { + ui.label(RichText::new(e).size(11.0).color(t.reject)); + } + if let Some(d) = ¤t { + let info = d.describe(); + if !screen { + row(ui, t, crate::i18n::tr("File"), |ui| { + path_label(ui, "settingsDisplayFile", &d.path, egui::FontId::proportional(11.0), t.text_dim, 0.0); + }); + } + row(ui, t, crate::i18n::tr("Type"), |ui| { + let kind = match d.profile.kind { + lightcraft_engine::display::DisplayKind::MatrixTrc => crate::i18n::tr("Matrix/TRC"), + lightcraft_engine::display::DisplayKind::Lut => crate::i18n::tr("LUT-based"), + }; + ui.label(RichText::new(kind).color(t.text)); + }); + row(ui, t, crate::i18n::tr("Primaries (x, y)"), |ui| { + let p = &info["primaries"]; + let xy = |(k, l): (&str, &str)| format!("{l} {:.3}, {:.3}", p[k][0].as_f64().unwrap_or(0.0), p[k][1].as_f64().unwrap_or(0.0)); + let text = [("red", "R"), ("green", "G"), ("blue", "B")].map(xy).join(" "); + ui.label(RichText::new(text).size(11.0).color(t.text_dim)); + }); + } + hint( + ui, + t, + crate::i18n::tr( + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.", + ), + ); + #[cfg(target_os = "linux")] + hint(ui, t, crate::i18n::tr("Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.")); +} + +pub(super) fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let system = app.system_theme(ui.ctx()); + appearance(ui, t, &mut app.ui.settings, system); + heading(ui, t, crate::i18n::tr("Filmstrip")); + check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); + check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); + heading(ui, t, crate::i18n::tr("Grid")); + row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| { + choices( + ui, + "settingsGridBadges", + &[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")], + &mut app.ui.settings.grid_badges, + ); + }); + check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); + heading(ui, t, crate::i18n::tr("Keywords")); + row(ui, t, crate::i18n::tr("Keywords of the selection"), |ui| { + use crate::state::KeywordChips as K; + choices(ui, "settingsKeywordChips", &[(K::Paths, "Whole names"), (K::Grouped, "Grouped by parent")], &mut app.ui.settings.keyword_chips); + }); + hint( + ui, + t, + crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), + ); + heading(ui, t, crate::i18n::tr("Detail View")); + check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); + row(ui, t, crate::i18n::tr("Info overlay"), |ui| { + use crate::state::InfoOverlay as I; + choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); + }); + hint(ui, t, crate::i18n::tr("View → View Options chooses the lines Info 1 and Info 2 show.")); +} + +/// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and +/// which, the three lines each of Info 1 and Info 2 shows, which of them shows briefly when the +/// photo changes, and whether the loupe shows its loading and rendering message +/// (`view.viewOptions`). Changes apply as they are made. +pub(in super::super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; + heading(ui, t, crate::i18n::tr("Loupe View")); + let mut show = app.ui.info_overlay != I::Off; + if check(ui, "viewOptions.showInfo", &mut show, "Show Info Overlay") { + app.ui.info_overlay = if show { I::Basic } else { I::Off }; + } + ui.add_enabled_ui(show, |ui| { + ui.horizontal(|ui| { + choices(ui, "viewOptionsInfo", &[(I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); + }); + }); + for (n, title, defaults) in [(1, "Loupe Info 1", LoupeInfo::INFO1), (2, "Loupe Info 2", LoupeInfo::INFO2)] { + heading(ui, t, crate::i18n::tr(title)); + let lines = if n == 1 { &mut app.ui.settings.loupe_info.info1 } else { &mut app.ui.settings.loupe_info.info2 }; + for (i, line) in lines.iter_mut().enumerate() { + let id = format!("viewOptionsInfo{n}Line{}", i + 1); + let r = crate::widgets::combo_box(&id).width(220.0).selected_text(crate::i18n::tr(line.label())).show_ui(ui, |ui| { + for f in F::ALL { + let key = serde_json::to_value(f).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let r = ui.selectable_label(line == f, crate::i18n::tr(f.label())); + register(ui, format!("button:{id}-{key}"), r.rect); + if r.clicked() { + *line = *f; + } + } + }); + crate::widgets::name_combo( + &r.response, + &crate::i18n::tr_format!("{title}: {line}", title = crate::i18n::tr(title), line = crate::i18n::tr(line.label())), + ); + register(ui, format!("combo:{id}"), r.response.rect); + } + let r = ui.add_enabled(*lines != defaults, egui::Button::new(crate::i18n::tr("Use Defaults"))); + register(ui, format!("button:viewOptionsDefaults{n}"), r.rect); + if r.clicked() { + *lines = defaults; + } + // one Info at most shows briefly: ticking one unticks the other + let info = if n == 1 { I::Basic } else { I::Exposure }; + let mut briefly = app.ui.settings.loupe_info.briefly == info; + if check(ui, &format!("viewOptions.briefly{n}"), &mut briefly, "Show briefly when photo changes") { + app.ui.settings.loupe_info.briefly = if briefly { info } else { I::Off }; + } + } + heading(ui, t, crate::i18n::tr("General")); + check(ui, "viewOptions.loupeMessages", &mut app.ui.settings.loupe_messages, "Show message when loading or rendering photos"); +} + +// ----------------------------------------------------------------------------------- Slideshow + +/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory +/// (the slideshow reads it); letting go saves it through `slideshow.playback`. +pub(super) fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive) { + let mut v = value; + let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1)); + register(ui, format!("slider:{id}"), r.rect); + if r.changed() + && let Ok(next) = app.session.slideshow.merged(&json!({ key: v })) + { + app.session.slideshow = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("slideshow.playback", json!({ key: v })); + } +} + +// ---------------------------------------------------------------------------- Display profile + +/// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile +/// (`""` or `null`: none, the profile of the screen showing the window where the host knows it +/// (macOS), else the display is treated as sRGB) and keep it as the setting; no `path`: report +/// only. A file that can't be used is an error and changes nothing. Returns the profile in use: +/// `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`, with +/// `path: null, screen: true` for the screen's profile. +pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { + let path = match p.get("path") { + None => None, + Some(Value::Null) => Some(String::new()), + Some(Value::String(s)) => Some(s.trim().to_string()), + Some(_) => return Err("app.displayProfile: path must be a string or null".into()), + }; + if let Some(path) = path { + let d = lightcraft_engine::display::Display::load_opt(&path).map_err(|e| format!("app.displayProfile: {e}"))?; + app.renderer.set_display(d); + app.ui.settings.display_profile = path.clone(); + app.display_applied = Some(path); + app.display_error = None; + app.screen_profile = None; + app.screen_profile_read = None; + if app.ui.settings.display_profile.is_empty() { + app.apply_screen_profile(); + } + } + Ok(match app.renderer.display() { + Some(d) if d.path == crate::SCREEN_PROFILE => { + let mut v = d.describe(); + v["path"] = Value::Null; + v["screen"] = json!(true); + v + } + Some(d) => d.describe(), + None => json!({"path": null, "description": "sRGB (no display profile)"}), + }) +} diff --git a/crates/ui-egui/src/dialogs/settings/general.rs b/crates/ui-egui/src/dialogs/settings/general.rs new file mode 100644 index 000000000..21b94f150 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/general.rs @@ -0,0 +1,175 @@ +use super::widgets::{check, choices, heading, hint, path_label, row}; +use super::*; + +// ------------------------------------------------------------------------------------- General + +pub(super) fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + row(ui, t, crate::i18n::tr("Language"), |ui| { + let languages: Vec<_> = crate::i18n::Locale::for_picker().into_iter().map(|language| (language, language.name())).collect(); + // Thirteen language names do not fit one line in a narrow window: wrap them. + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + }); + crate::i18n::set_language(app.ui.language); + }); + heading(ui, t, crate::i18n::tr("Library")); + let location = match &app.session.library { + Some(l) => l.dir.display().to_string(), + None => crate::i18n::tr("In memory — nothing is saved").to_string(), + }; + row(ui, t, crate::i18n::tr("Location"), |ui| { + path_label(ui, "settingsLocation", &location, egui::TextStyle::Body.resolve(ui.style()), t.text, 0.0); + }); + row(ui, t, "", |ui| { + let can = app.services.pick_folder.is_some(); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…"))); + register(ui, "button:settingsOpenLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.openLibrary", json!({})); + } + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…"))); + register(ui, "button:settingsNewLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.newLibrary", json!({})); + } + if !can { + hint(ui, t, crate::i18n::tr("not available here")); + } + }); + // where libraries are folders to choose from (not in the browser, which keeps one) + if app.services.pick_folder.is_some() { + use crate::state::LaunchLibrary; + row(ui, t, crate::i18n::tr("At launch"), |ui| { + choices( + ui, + "settingsAtLaunch", + &[(LaunchLibrary::Last, "Open the last library"), (LaunchLibrary::Ask, "Ask me")], + &mut app.ui.settings.at_launch, + ); + }); + hint(ui, t, crate::i18n::tr("Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.")); + } + // File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not + ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label)); + row(ui, t, "", |ui| { + use crate::state::LightroomImport; + choices( + ui, + "settingsLightroomImport", + &[(LightroomImport::Ask, "Ask where to import it"), (LightroomImport::AlwaysMerge, "Always merge into the open library")], + &mut app.ui.settings.lightroom_import, + ); + }); + heading(ui, t, crate::i18n::tr("Startup")); + row(ui, t, crate::i18n::tr("Open in"), |ui| { + choices( + ui, + "settingsStartup", + &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail View")], + &mut app.ui.settings.startup_view, + ); + }); + heading(ui, t, crate::i18n::tr("Culling")); + check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); + check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); + check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); + heading(ui, t, crate::i18n::tr("Dialogs")); + check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it"); + heading(ui, t, crate::i18n::tr("External Editing")); + row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { + crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) + .hint(crate::i18n::tr("Found automatically")) + .width(220.0) + .show(ui); + }); + edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); + hint( + ui, + t, + crate::i18n::tr( + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.", + ), + ); + row(ui, t, crate::i18n::tr("Other editor"), |ui| { + crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) + .hint(crate::i18n::tr("System default")) + .width(220.0) + .show(ui); + }); + edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); + hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); + hint( + ui, + t, + crate::i18n::tr( + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).", + ), + ); +} + +/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). +fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) { + use crate::state::{EditFormat, EditSpace}; + row(ui, t, "", |ui| { + choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format); + ui.separator(); + choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth); + }); + row(ui, t, "", |ui| { + choices( + ui, + &format!("{id}Space"), + &[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")], + &mut file.color_space, + ); + }); +} + +// -------------------------------------------------------------------------------------- Import + +/// A preset picker: `None` = `none_label`. Returns the new choice when it changed. +pub(super) fn preset_combo( + app: &LightcraftApp, + ui: &mut egui::Ui, + id: &str, + label: &str, + current: Option<&str>, + none_label: &str, +) -> Option> { + let name = |pid: &str| { + app.session + .presets + .iter() + .find(|p| p.id == pid) + .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) + .unwrap_or_else(|| crate::i18n::tr_format!("{pid} (missing)", pid = pid)) + }; + let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string()); + let mut out = None; + let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| { + if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { + out = Some(None); + } + if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) { + out = Some(Some(pid)); + } + }); + crate::widgets::name_combo(&r.response, label); + register(ui, format!("combo:{id}"), r.response.rect); + out.filter(|v| v.as_deref() != current) +} + +/// Cameras of the raws in the library plus those with a stored default, sorted. +pub(super) fn cameras(app: &LightcraftApp) -> Vec { + let mut v: Vec = app + .session + .catalog + .photos() + .filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty()) + .map(|p| p.meta.camera.clone()) + .chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone())) + .collect(); + v.sort_by_key(|c| c.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v +} diff --git a/crates/ui-egui/src/dialogs/settings/import.rs b/crates/ui-egui/src/dialogs/settings/import.rs new file mode 100644 index 000000000..c7272d366 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/import.rs @@ -0,0 +1,187 @@ +use super::general::{cameras, preset_combo}; +use super::widgets::{button_width, check, choices, heading, hint, path_label, row}; +use super::*; + +pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let d = app.session.import_defaults.clone(); + heading(ui, t, crate::i18n::tr("Raw defaults")); + hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library.")); + row(ui, t, crate::i18n::tr("Raw photos"), |ui| { + if let Some(v) = preset_combo(app, ui, "settingsRawPreset", crate::i18n::tr("Raw default"), d.raw_preset.as_deref(), "LightCraft Default") { + let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}})); + } + }); + let mut per = d.per_camera; + row(ui, t, "", |ui| { + if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") { + let _ = app.run("library.preferences", json!({"import": {"perCamera": per}})); + } + }); + if per { + let cams = cameras(app); + if cams.is_empty() { + hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library.")); + } + egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| { + for (i, cam) in cams.iter().enumerate() { + let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam)); + row(ui, t, cam, |ui| { + // "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default) + const RAW_DEFAULT: &str = "\u{1}raw"; + let current = match entry { + None => Some(RAW_DEFAULT), + Some(e) => e.preset.as_deref(), + }; + let mut pick = None; + let label = match current { + Some(RAW_DEFAULT) => crate::i18n::tr("Same as raw default").to_string(), + None => crate::i18n::tr("LightCraft Default").to_string(), + Some(pid) => app + .session + .presets + .iter() + .find(|p| p.id == pid) + .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) + .unwrap_or_else(|| pid.to_string()), + }; + let id = format!("settingsCamera-{i}"); + let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { + pick = Some(json!({"camera": cam, "remove": true})); + } + if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { + pick = Some(json!({"camera": cam, "preset": null})); + } + if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) { + pick = Some(json!({"camera": cam, "preset": pid})); + } + }); + crate::widgets::name_combo(&r.response, crate::i18n::tr("Camera")); + register(ui, format!("combo:{id}"), r.response.rect); + if let Some(c) = pick { + let _ = app.run("library.preferences", json!({"camera": c})); + } + }); + } + }); + } + heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)")); + row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| { + if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", crate::i18n::tr("Other preset"), d.other_preset.as_deref(), "None") { + let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); + } + }); + heading(ui, t, crate::i18n::tr("Metadata")); + hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it.")); + for (key, label, hint_text, value) in + [("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())] + { + row(ui, t, label, |ui| { + let id = egui::Id::unique(("settingsMeta", key)); + let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0)); + register(ui, format!("field:settings.{key}"), r.rect); + if r.lost_focus() && text.trim() != value { + let _ = app.run("library.preferences", json!({"import": {key: text.trim()}})); + } + if r.has_focus() { + ui.data_mut(|m| m.insert_temp(id, text)); + } else { + ui.data_mut(|m| m.remove::(id)); + } + }); + } + if !app.session.metadata_presets.is_empty() { + row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { + let cur = d.metadata_preset.clone(); + let mut pick = None; + let r = crate::widgets::combo_box("settingsMetaPreset") + .width(240.0) + .selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into())) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() { + pick = Some(String::new()); + } + for m in &app.session.metadata_presets { + if ui.selectable_label(cur.as_deref() == Some(m.name.as_str()), &m.name).clicked() { + pick = Some(m.name.clone()); + } + } + }); + crate::widgets::name_combo(&r.response, crate::i18n::tr("Metadata preset")); + register(ui, "combo:settingsMetaPreset", r.response.rect); + if let Some(n) = pick { + let _ = app.run("library.preferences", json!({"import": {"metadataPreset": n}})); + } + }); + } + heading(ui, t, crate::i18n::tr("XMP sidecars")); + let mut xmp = app.session.xmp; + if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { + let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write})); + } + row(ui, t, crate::i18n::tr("Sidecar names"), |ui| { + use lightcraft_engine::sidecar::SidecarNaming as N; + let mut n = xmp.naming; + if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) { + let naming = if n == N::Full { "full" } else { "stem" }; + let _ = app.run("library.xmpPreferences", json!({"naming": naming})); + } + }); + if !cfg!(target_arch = "wasm32") { + heading(ui, t, crate::i18n::tr("Dropped Files")); + check(ui, "settings.importDropped", &mut app.ui.settings.import_dropped_directly, "Import dropped files without review"); + heading(ui, t, crate::i18n::tr("Cameras and Cards")); + let mut on_card = d.show_on_card; + if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { + let _ = app.run("library.preferences", json!({"import": {"showOnCard": on_card}})); + } + heading(ui, t, crate::i18n::tr("Auto Import")); + hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); + row(ui, t, crate::i18n::tr("Watched folder"), |ui| { + match &d.auto_folder { + Some(folder) => path_label( + ui, + "settingsAutoFolder", + folder, + egui::TextStyle::Body.resolve(ui.style()), + t.text, + button_width(ui, crate::i18n::tr("Choose…")) + button_width(ui, crate::i18n::tr("Turn Off")), + ), + None => drop(ui.label(RichText::new(crate::i18n::tr("Off")).color(t.text))), + } + let can = app.services.pick_folder.is_some(); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…"))); + register(ui, "button:settingsAutoFolder", r.rect); + if r.clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) + && let Err(e) = app.run("library.autoImport", json!({"folder": f})) + { + app.toast(ui.ctx(), e); + } + if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() { + let _ = app.run("library.autoImport", json!({"folder": null})); + } + }); + if d.auto_folder.is_some() { + row(ui, t, "", |ui| { + let mut copy = d.auto_copy; + if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() { + let _ = app.run("library.autoImport", json!({"copy": copy})); + } + }); + row(ui, t, crate::i18n::tr("Album"), |ui| { + let id = egui::Id::unique("auto-album"); + let mut name = ui.data(|m| m.get_temp::(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default()); + let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0)); + if r.lost_focus() { + let _ = app.run("library.autoImport", json!({"album": name.trim()})); + } + ui.data_mut(|m| m.insert_temp(id, name)); + }); + } + } + if app.session.library.is_none() { + hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits.")); + } +} diff --git a/crates/ui-egui/src/dialogs/settings/libraries.rs b/crates/ui-egui/src/dialogs/settings/libraries.rs new file mode 100644 index 000000000..389f79f11 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/libraries.rs @@ -0,0 +1,313 @@ +use super::*; + +// ------------------------------------------------------------------------------- Open Library… + +/// Switching libraries is refused while an import (or the folder scan before one) is running: its +/// remaining batches would be committed into the new library (issue #1509). +pub(crate) fn can_switch_library(app: &LightcraftApp) -> Result<(), String> { + if crate::lightroom_import::is_running(app) { + return Err("wait for Lightroom catalog import to finish before switching libraries".into()); + } + if app.import.is_some() || app.scan.is_some() { + return Err("wait for the import to finish before switching libraries".into()); + } + Ok(()) +} + +/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, +/// or a folder chosen in a dialog. Remembered as the library to open at launch. +/// +/// A Lightroom Classic catalog (its folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` +/// itself, see [`lightcraft_engine::lightroom_folder`]) is not opened: the current library stays +/// and a dialog offers Import Lightroom Catalog… instead; returns `{opened: false, +/// lightroomCatalog: <.lrcat path or null>, path}`. +pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { + can_switch_library(app)?; + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library")); + match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } + }; + // A Lightroom catalog folder (or part of one) is not a library: before closing the library + // open now, offer to import it instead (issue #891). The engine refuses it too. + if let Some(place) = lightcraft_engine::lightroom_folder::lightroom_catalog_at(std::path::Path::new(&path)) { + let catalog = place.catalog.map(|c| c.to_string_lossy().into_owned()); + let err = lightcraft_engine::EngineError::LightroomCatalog { path: path.clone().into(), catalog: catalog.clone().map(Into::into) }; + // the library problem window (no library could be opened) shows the error instead + if app.library_problem.is_some() { + return Err(err.to_string()); + } + app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() }); + return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path})); + } + // a remembered library that is gone: say so (and forget it) instead of creating an empty one there + if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() { + app.ui.settings.recent_libraries.retain(|r| r != &path); + return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library")); + } + switch_library(app, &path) +} + +/// Close the current library and open the one at `path`. If it can't be opened (locked by +/// another program, unreadable, a file), the error is returned and the current library stays open: +/// the target is checked before anything is closed, and if opening it still fails the previous +/// library is opened again (both errors are reported if that fails too). +fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { + let dir = app.session.check_library(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + let path = dir.to_string_lossy().into_owned(); + // the library being left goes in the list too (also when it was opened at launch) + let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); + app.session.close_library().map_err(|e| e.to_string())?; + let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); + app.lightroom_last = None; + app.lightroom_result = None; + if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) { + app.ui.dialog = None; + } + app.renderer.forget_all(); + if let Err(err) = opened { + let Some(prev) = previous_dir else { return Err(err) }; + return match app.session.open_library(&prev, false) { + Ok(_) => Err(err), + Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())), + }; + } + // Photo IDs are local to a catalog: pins cannot follow them into another library (a refused + // or failed switch reopens the same catalog, so its pins stay). + app.ui.reference = None; + app.ui.compare = None; + app.ui.brief_info = crate::state::BriefInfo::default(); + if app.ui.view == crate::state::ViewMode::Reference { + app.ui.view = crate::state::ViewMode::PhotoGrid; + } + app.ui.settings.library_path = path.clone(); + if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { + remember_library(&mut app.ui.settings.recent_libraries, &prev); + } + remember_library(&mut app.ui.settings.recent_libraries, &path); + Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) +} + +/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`]. +pub fn remember_library(recent: &mut Vec, path: &str) { + recent.retain(|r| r != path); + recent.insert(0, path.to_string()); + recent.truncate(crate::state::RECENT_LIBRARIES); +} + +/// A library's name as people see it: its folder's name. +pub fn library_name(dir: &std::path::Path) -> String { + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) +} + +/// The open library's name (`None`: in memory, nothing is saved). +pub fn open_library_name(app: &LightcraftApp) -> Option { + app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir)) +} + +/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library +/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it, +/// `path` itself, or a new folder named after the default library. The folder must not hold a +/// library already, nor anything else. +pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result { + can_switch_library(app)?; + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(valid_library_name(s)?), + Some(_) => return Err("app.newLibrary: name must be a string".into()), + }; + // from the menu: ask for the name first (default prefilled), then for the location + if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() { + crate::panels::dialogs::prompt( + app, + "New Library", + "Library name", + lightcraft_engine::library::DEFAULT_NAME, + "app.newLibrary", + json!({}), + "name", + ); + return Ok(Value::Null); + } + let (location, picked) = match p.get("path").and_then(Value::as_str) { + Some(x) => (std::path::PathBuf::from(x), false), + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It")); + match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => (std::path::PathBuf::from(x), true), + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } + }; + let dir = match name { + Some(n) => location.join(n), + // a chosen location gets a new folder in it, named after the default library + None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME), + None => location, + }; + if lightcraft_engine::library::is_library(&dir) { + return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display())); + } + if dir.is_file() { + return Err(format!("{}: is a file; a library is a folder", dir.display())); + } + if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { + return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); + } + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?; + switch_library(app, &dir.to_string_lossy()) +} + +/// Characters a folder name can't hold on Windows (and `/`, which no system allows). +const RESERVED_NAME_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +fn valid_library_name(s: &str) -> Result<&str, String> { + let n = s.trim(); + // Windows drops trailing dots and spaces from a folder name: refuse them rather than + // creating a folder whose name differs from the one asked for (spaces around the name + // are trimmed first) + let bad = n.is_empty() || n == "." || n == ".." || n.ends_with(['.', ' ']) || n.contains(RESERVED_NAME_CHARS) || n.chars().any(char::is_control); + if bad { + return Err(format!( + "app.newLibrary: {s:?} can't be a library name (a folder name without < > : \" / \\ | ? * and not ending in a dot or space)" + )); + } + Ok(n) +} + +/// `base` inside `location`, or `base 2`, `base 3`… if that is taken. +pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { + let first = location.join(base); + if !first.exists() { + return first; + } + (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// `app.recentLibraries {remove?: path, clear?: bool}`: the recently opened libraries, newest +/// first, as `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last +/// background check found (`false`: moved, on a disconnected drive, not a folder or no longer a +/// library; `null`: not checked yet, or the check couldn't tell — a folder that can't be listed, an +/// unreadable catalog, a lookup or check that failed; `checking` says whether a check is running): the command never +/// looks at a folder itself, as one on an offline network share can take seconds to answer. +/// `lastOpened`: ISO 8601 time, `null` when not known. `remove` forgets that +/// one entry (an error if the list doesn't hold it) and `clear` forgets them all; neither touches +/// anything on disk (issue #1290). +pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { + match p.get("remove") { + None | Some(Value::Null) => {} + Some(Value::String(path)) => { + if !forget_library(&mut app.ui.settings, path) { + return Err(format!("app.recentLibraries: {path:?} is not in the recent libraries")); + } + app.recent_checks.forget(path); + } + Some(_) => return Err("app.recentLibraries: remove must be a library path (a string)".into()), + } + if p.get("clear").and_then(Value::as_bool) == Some(true) { + for path in std::mem::take(&mut app.ui.settings.recent_libraries) { + app.recent_checks.forget(&path); + } + app.ui.settings.recent_opened.clear(); + } + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let (settings, checks) = (&app.ui.settings, &app.recent_checks); + let list: Vec = settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + let exists = checks.exists(r); + json!({"path": r, "name": library_name(d), "exists": exists, "checking": checks.is_checking(r), "open": open.as_deref() == Some(d), "lastOpened": settings.recent_opened.get(r)}) + }) + .collect(); + Ok(Value::Array(list)) +} + +/// Forget `path` in File ▸ Open Recent Library (and when it was opened); the library itself is +/// left where it is. False if the list didn't hold it. +pub fn forget_library(settings: &mut crate::state::AppSettings, path: &str) -> bool { + let before = settings.recent_libraries.len(); + settings.recent_libraries.retain(|r| r != path); + settings.recent_opened.remove(path); + settings.recent_libraries.len() != before +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn app_with_import() -> LightcraftApp { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.run("photo.setReference", json!({})).unwrap(); + app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); + app + } + + #[test] + fn open_library_is_refused_while_an_import_runs() { + let mut app = app_with_import(); + let pin = app.ui.reference; + let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert!(app.import.is_some()); + assert_eq!(app.ui.reference, pin, "a refused Open Library keeps the reference pin"); + } + + #[test] + fn new_library_is_refused_while_an_import_runs() { + let mut app = app_with_import(); + let pin = app.ui.reference; + let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert!(app.import.is_some()); + assert_eq!(app.ui.reference, pin, "a refused New Library keeps the reference pin"); + } + + /// The menu items that switch libraries are greyed out meanwhile, like during a Lightroom import. + #[test] + fn library_switching_menu_items_are_disabled_while_an_import_runs() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.services.pick_folder = Some(Box::new(|| None)); + assert!(crate::menus::ui_enabled(&app, "app.openLibrary")); + assert!(crate::menus::ui_enabled(&app, "app.newLibrary")); + app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); + assert!(!crate::menus::ui_enabled(&app, "app.openLibrary")); + assert!(!crate::menus::ui_enabled(&app, "app.newLibrary")); + } + + /// The folder scan before an import's review counts too: it ends in an import into the open library. + #[test] + fn open_library_is_refused_while_an_import_scan_runs() { + let dir = std::env::temp_dir().join(format!("lc-ui-switchscan-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.run("photo.setReference", json!({})).unwrap(); + let pin = app.ui.reference; + crate::import::open(&mut app, vec![dir.to_string_lossy().into_owned()]).unwrap(); + assert!(app.scan.is_some()); + let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert_eq!(app.ui.reference, pin, "a refused Open Library during a scan keeps the reference pin"); + let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert_eq!(app.ui.reference, pin, "a refused New Library during a scan keeps the reference pin"); + assert!(app.scan.is_some()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/dialogs/settings/performance.rs b/crates/ui-egui/src/dialogs/settings/performance.rs new file mode 100644 index 000000000..daf2fa435 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/performance.rs @@ -0,0 +1,185 @@ +use super::widgets::{check, choices, heading, hint, row}; +#[cfg(not(target_arch = "wasm32"))] +use super::widgets::{label_width, path_label}; +use super::*; + +// --------------------------------------------------------------------------------- Performance + +pub(super) fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::gpu; + heading(ui, t, crate::i18n::tr("Rendering")); + check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); + let status = if !gpu::available() { + match gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why), + None => "No usable GPU found: rendering on the CPU".to_string(), + } + } else { + format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) + }; + // One line, the whole text on hover: the reason can be a long driver message (no adapter + // among the backends …), and the GPU state is process-wide, so it may change between frames; + // a hint that wrapped would move every control below it under the pointer. + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status); + row(ui, t, crate::i18n::tr("Preview size"), |ui| { + let opts: Vec<(u32, String)> = + PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); + let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); + choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_limit); + }); + hint( + ui, + t, + crate::i18n::tr( + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.", + ), + ); + row(ui, t, crate::i18n::tr("Memory for caches"), |ui| { + let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); + let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; + choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb); + }); + heading(ui, t, crate::i18n::tr("Thumbnail cache")); + let cur = (app.session.cache_bytes() >> 20) as u32; + row(ui, t, crate::i18n::tr("Size limit"), |ui| { + let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")]; + let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 }; + if choices(ui, "settingsCache", &opts, &mut v) { + let _ = app.run("library.preferences", json!({"cacheMb": v})); + } + }); + let used = app.session.media.rendered.disk().map(|d| d.size()); + row(ui, t, crate::i18n::tr("In use"), |ui| { + ui.label( + RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) + .color(t.text), + ); + let r = ui.button(crate::i18n::tr("Clear Cache")); + register(ui, "button:settingsClearCache", r.rect); + if r.clicked() { + let _ = app.run("library.clearPreviews", json!({})); + } + }); + heading(ui, t, crate::i18n::tr("Local folders")); + row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| { + let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")]; + let mut v = app.session.forget_local_days; + if choices(ui, "settingsForgetLocal", &opts, &mut v) { + let _ = app.run("library.preferences", json!({"forgetLocalDays": v})); + } + }); + hint( + ui, + t, + crate::i18n::tr( + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.", + ), + ); + #[cfg(not(target_arch = "wasm32"))] + smart_previews(app, ui, t); +} + +/// Where this library keeps its smart previews (the offline-editing proxies, which can be large): +/// the effective folder, what is in it, and choosing another one. +#[cfg(not(target_arch = "wasm32"))] +fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + // listing a big folder every frame would be slow: refresh every 2 s and after a change + let cache = egui::Id::unique("smart-location"); + let now = ui.input(|i| i.time); + let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) { + Some((at, v)) if now - at < 2.0 => v, + _ => { + let v = app.run("library.smartPreviewsLocation", json!({})).unwrap_or(Value::Null); + ui.data_mut(|d| d.insert_temp(cache, (now, v.clone()))); + v + } + }; + if loc.is_null() { + return; + } + heading(ui, t, crate::i18n::tr("Smart previews")); + let path = loc["path"].as_str().unwrap_or_default().to_string(); + let available = loc["available"].as_bool().unwrap_or(true); + let custom = loc["custom"].as_bool().unwrap_or(false); + row(ui, t, crate::i18n::tr("Folder"), |ui| { + path_label( + ui, + "settingsSmartFolder", + &path, + egui::TextStyle::Body.resolve(ui.style()), + if available { t.text } else { t.reject }, + if custom { 0.0 } else { label_width(ui, "(library default)", egui::TextStyle::Body) }, + ); + if !custom { + let r = ui.label(RichText::new("(library default)").color(t.text)); + register(ui, "text:settingsSmartDefault", r.rect); + } + }); + let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); + if available { + hint( + ui, + t, + &crate::i18n::tr_format!( + "{count} smart preview{} · {:.1} MB", + if count == 1 { "" } else { "s" }, + bytes as f64 / 1048576.0, + count = count + ), + ); + } else { + hint( + ui, + t, + crate::i18n::tr( + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.", + ), + ); + } + // what happens to the previews already built when the folder changes + let mode_id = egui::Id::unique("smart-existing"); + let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0); + if count > 0 { + row(ui, t, crate::i18n::tr("Existing previews"), |ui| { + if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) { + ui.data_mut(|d| d.insert_temp(mode_id, mode)); + } + }); + } + let existing = ["move", "leave", "discard"][mode.min(2) as usize]; + let mut result = None; + ui.horizontal(|ui| { + ui.add_space(LABEL_W); + if app.services.pick_folder.is_some() { + let r = ui.button(crate::i18n::tr("Choose Folder…")); + register(ui, "button:settingsSmartChoose", r.rect); + if r.clicked() + && let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing}))); + } + } + let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder"))); + register(ui, "button:settingsSmartReset", r.rect); + if r.clicked() { + result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing}))); + } + }); + if let Some(r) = result { + ui.data_mut(|d| d.remove::<(f64, Value)>(cache)); + match r { + Ok(v) => { + let failed = v["failed"].as_array().map_or(0, Vec::len); + let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) { + (_, 0) => "Smart previews folder changed".to_string(), + ("move", n) => format!("Smart previews folder changed; moved {n}"), + ("discard", n) => format!("Smart previews folder changed; deleted {n}"), + _ => "Smart previews folder changed".to_string(), + }; + app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg }); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.")); +} diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/dialogs/settings/slide_layout.rs similarity index 94% rename from crates/ui-egui/src/panels/slide_layout.rs rename to crates/ui-egui/src/dialogs/settings/slide_layout.rs index 802e62a5c..2fc55abda 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_layout.rs @@ -14,8 +14,8 @@ use lightcraft_engine::slide_template::SlideLook; use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; use serde_json::{Value, json}; -use super::settings::{check, choices, heading, hint, row}; use crate::LightcraftApp; +use crate::panels::settings::{check, choices, heading, hint, row}; use crate::render::Slot; use crate::theme::Tokens; use crate::widgets::register; @@ -45,7 +45,7 @@ fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value Unit::Degrees => (value, -180.0..=180.0, "°"), }; let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(1)); - register(ui.ctx(), format!("slider:{id}"), r.rect); + register(ui, format!("slider:{id}"), r.rect); let value = match unit { Unit::Percent => v / 100.0, Unit::Degrees => v, @@ -64,7 +64,7 @@ fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: [u8; 3]) { let mut c = value; let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); - register(ui.ctx(), format!("button:{id}"), r.rect); + register(ui, format!("button:{id}"), r.rect); if r.changed() { set(app, json!({ key: c })); } @@ -132,7 +132,7 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let active = app.session.active(); let mut on = s.background_image.is_some(); let r = ui.add_enabled(on || active.is_some(), egui::Checkbox::new(&mut on, crate::i18n::tr("Background image"))); - register(ui.ctx(), "check:settings.slideshowBackgroundImage", r.rect); + register(ui, "check:settings.slideshowBackgroundImage", r.rect); if r.changed() { set(app, json!({"backgroundImage": if on { active.map(|a| a.0) } else { None }})); } @@ -169,7 +169,7 @@ fn screen_aspect(ctx: &egui::Context) -> f64 { pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f32) { heading(ui, t, "Preview"); // the template the pointer is over in the Template Browser, else the settings - let template = super::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); + let template = crate::panels::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); let look = template.as_ref().map_or_else(|| SlideLook::of(&app.session), |t| t.look.clone()); let s = look.layout.clone(); let overlays = look.overlays.clone(); @@ -185,10 +185,10 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 let rect = Rect::from_center_size(room.center(), vec2(fit_w, fit_h)); let h = rect.height(); let w = rect.width(); - register(ui.ctx(), "slideshow:preview", rect); + register(ui, "slideshow:preview", rect); let p = ui.painter_at(room.expand(1.0)); p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); - super::slide_titles::preview_choice(ui); + crate::panels::slide_titles::preview_choice(ui); if let Some(tpl) = &template { // (said over the bottom of the preview and not below it, which would move the page) let name = if tpl.builtin { crate::i18n::tr(&tpl.name).to_string() } else { tpl.name.clone() }; @@ -203,12 +203,12 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 ); let paint = ui .ctx() - .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("settingsSlideshowTemplateSaid"))) + .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("settingsSlideshowTemplateSaid"))) .with_clip_rect(room.intersect(ui.clip_rect())); paint.rect_filled(band, 0.0, Color32::from_black_alpha(190)); paint.galley(pos2(band.left() + 4.0, band.top() + 3.0), galley, Color32::WHITE); } - if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) { + if let Some(title) = crate::panels::slide_titles::preview_shows(ui.ctx()) { title_preview(app, ui, t, rect, title, &look); return; } @@ -230,7 +230,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 let overlay_key = if overlays.any() { overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 }; let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlay_key.rotate_left(29); let now = ui.input(|i| i.time); - let mem = egui::Id::new("settingsSlideshowPreviewKey"); + let mem = egui::Id::unique("settingsSlideshowPreviewKey"); let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id); if last != key { @@ -239,7 +239,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 let still = if last == key { now - since } else { 0.0 }; // the first preview (or another photo's) at once, a changing layout once it has settled if current.is_none() || still >= DEBOUNCE { - let side = super::detail::texture_side(ui.ctx()); + let side = crate::panels::detail::texture_side(ui.ctx()); let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side); if let Some(job) = app.session.slide_job_look(&s, &overlays, id, pw, ph, Some(edge), false) { app.renderer.request(Slot::SlidePreview, job, 90); @@ -252,7 +252,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); return; }; - let drawn = super::detail::fit_texture_rect(rect, tex.size); + let drawn = crate::panels::detail::fit_texture_rect(rect, tex.size); p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); if s.show_guides { // the margins, as lines across the slide (where the image cell's edges are) @@ -280,7 +280,7 @@ fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: R let compose = std::sync::Arc::new(lightcraft_engine::slide_title::TitleCompose::new(&look.titles, &look.overlays, title, pw, ph)); let key = compose.key(); let now = ui.input(|i| i.time); - let mem = egui::Id::new("settingsSlideshowTitlePreviewKey"); + let mem = egui::Id::unique("settingsSlideshowTitlePreviewKey"); let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); if last != key { ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now))); @@ -296,7 +296,7 @@ fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: R let p = ui.painter_at(rect.expand(1.0)); match app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0)) { Some(tex) => { - let drawn = super::detail::fit_texture_rect(rect, tex.size); + let drawn = crate::panels::detail::fit_texture_rect(rect, tex.size); p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { diff --git a/crates/ui-egui/src/panels/slide_overlays.rs b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs similarity index 97% rename from crates/ui-egui/src/panels/slide_overlays.rs rename to crates/ui-egui/src/dialogs/settings/slide_overlays.rs index 3824eeb0d..3d7412b58 100644 --- a/crates/ui-egui/src/panels/slide_overlays.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs @@ -13,8 +13,8 @@ use lightcraft_engine::slide_overlay::{ }; use serde_json::{Value, json}; -use super::settings::{check, choices, heading, hint, row}; use crate::LightcraftApp; +use crate::panels::settings::{check, choices, heading, hint, row}; use crate::text_field::TextField; use crate::theme::Tokens; use crate::widgets::register; @@ -38,7 +38,7 @@ fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: }; let mut v = value * k; let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); - register(ui.ctx(), format!("slider:{id}"), r.rect); + register(ui, format!("slider:{id}"), r.rect); let p = wrap(v / k); if r.changed() && let Ok(next) = app.session.slide_overlays.merged(&p) @@ -54,7 +54,7 @@ fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: [u8; 3], wrap: &dyn Fn([u8; 3]) -> Value) { let mut c = value; let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); - register(ui.ctx(), format!("button:{id}"), r.rect); + register(ui, format!("button:{id}"), r.rect); if r.changed() { set(app, wrap(c)); } @@ -71,7 +71,7 @@ fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, lab /// A one-line text field (`field:{id}`), saved when the edit ends. fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) { - let mem = egui::Id::new(("slideshow-overlay-field", id)); + let mem = egui::Id::unique(("slideshow-overlay-field", id)); let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); let widget = format!("field:{id}"); let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui); @@ -147,7 +147,7 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let p = s.identity_plate.clone(); let plate = |v: Value| json!({ "identityPlate": v }); if toggle(app, ui, "slideshowPlate", p.enabled, "Identity plate", &|v| plate(json!({"enabled": v}))) { - let mem = egui::Id::new("slideshow-plate-graphic"); + let mem = egui::Id::unique("slideshow-plate-graphic"); let mut graphic = !p.image.is_empty() || ui.data(|d| d.get_temp::(mem)).unwrap_or(false); let before = graphic; row(ui, t, "Style", |ui| choices(ui, "slideshowPlateStyle", &[(false, "Text"), (true, "Image")], &mut graphic)); diff --git a/crates/ui-egui/src/panels/slide_templates.rs b/crates/ui-egui/src/dialogs/settings/slide_templates.rs similarity index 96% rename from crates/ui-egui/src/panels/slide_templates.rs rename to crates/ui-egui/src/dialogs/settings/slide_templates.rs index 51c27d224..d32cb0552 100644 --- a/crates/ui-egui/src/panels/slide_templates.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_templates.rs @@ -13,17 +13,17 @@ use egui::{RichText, Sense, Stroke, vec2}; use lightcraft_engine::slide_template::{PREVIOUS, SlideLook, Template}; use serde_json::json; -use super::settings::{heading, hint}; use crate::LightcraftApp; +use crate::panels::settings::{heading, hint}; use crate::text_field::TextField; use crate::theme::Tokens; -use crate::widgets::register; +use crate::widgets::{register, register_response}; /// The user templates list is at most this tall (it scrolls past it). const USER_LIST_H: f32 = 180.0; fn hovered_key() -> egui::Id { - egui::Id::new("settingsSlideshowTemplateHovered") + egui::Id::unique("settingsSlideshowTemplateHovered") } /// The template the pointer is over in the browser (its id), which the preview shows. @@ -43,7 +43,7 @@ enum Pending { } fn pending_key() -> egui::Id { - egui::Id::new("settingsSlideshowTemplatePending") + egui::Id::unique("settingsSlideshowTemplatePending") } fn pending(ctx: &egui::Context) -> Option { @@ -129,7 +129,7 @@ fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Te } let w = ui.available_width(); let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); - register(&ctx, format!("template:{}", tpl.id), rect); + register_response(format!("template:{}", tpl.id), rect, &resp); if resp.hovered() || resp.context_menu_opened() { *hovered = Some(tpl.id.clone()); } @@ -151,7 +151,7 @@ fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Te /// A menu entry registered as `button:{id}`. fn item(ui: &mut egui::Ui, id: &str, label: &str, enabled: bool) -> bool { let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label))); - register(ui.ctx(), format!("button:{id}"), r.rect); + register(ui, format!("button:{id}"), r.rect); r.clicked() } @@ -228,7 +228,7 @@ pub fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { // save the current settings as a new template ui.add_space(4.0); ui.horizontal(|ui| { - let mem = egui::Id::new("settingsSlideshowTemplateNewName"); + let mem = egui::Id::unique("settingsSlideshowTemplateNewName"); let mut name: String = ui.data(|d| d.get_temp(mem)).unwrap_or_default(); let r = TextField::singleline("field:slideshowTemplateName", &mut name).hint(crate::i18n::tr("New template name")).width(180.0).show(ui); let save = crate::widgets::text_button(ui, "slideshowTemplateSave", "Save as Template", false).clicked() diff --git a/crates/ui-egui/src/panels/slide_titles.rs b/crates/ui-egui/src/dialogs/settings/slide_titles.rs similarity index 96% rename from crates/ui-egui/src/panels/slide_titles.rs rename to crates/ui-egui/src/dialogs/settings/slide_titles.rs index 257790995..79d0dfd3f 100644 --- a/crates/ui-egui/src/panels/slide_titles.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_titles.rs @@ -12,15 +12,15 @@ use lightcraft_engine::slide_title::{TitleContent, TitleScreen}; use lightcraft_engine::slideshow::{SLIDE_MAX, SLIDE_MIN, Title}; use serde_json::{Value, json}; -use super::settings::{check, choices, heading, hint, row}; use crate::LightcraftApp; +use crate::panels::settings::{check, choices, heading, hint, row}; use crate::text_field::TextField; use crate::theme::Tokens; use crate::widgets::register; /// Where the preview's choice lives (egui memory): `None` = the photo's slide. fn preview_key() -> egui::Id { - egui::Id::new("settingsSlideshowPreviewShows") + egui::Id::unique("settingsSlideshowPreviewShows") } /// What the Settings preview shows: a title screen, or (`None`) the active photo's slide. @@ -64,7 +64,7 @@ fn slider( ) { let mut v = value * k; let r = ui.add(egui::Slider::new(&mut v, (range.start() * k)..=(range.end() * k)).suffix(suffix).max_decimals(1)); - register(ui.ctx(), format!("slider:{id}"), r.rect); + register(ui, format!("slider:{id}"), r.rect); let p = json!({ t.id(): { key: v / k } }); if r.changed() { if let Ok(next) = app.session.slide_titles.merged(&p) { @@ -81,7 +81,7 @@ fn slider( fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, key: &str, value: [u8; 3]) { let mut c = value; let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); - register(ui.ctx(), format!("button:{id}"), r.rect); + register(ui, format!("button:{id}"), r.rect); if r.changed() { set(app, ui.ctx(), t, key, json!(c)); } @@ -89,7 +89,7 @@ fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, key: & /// The screen's text field (`field:{id}`), saved when the edit ends. fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, value: &str) { - let mem = egui::Id::new(("slideshow-title-field", id)); + let mem = egui::Id::unique(("slideshow-title-field", id)); let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); let widget = format!("field:{id}"); let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr("Title text")).width(ui.available_width().clamp(80.0, 260.0)).show(ui); diff --git a/crates/ui-egui/src/dialogs/settings/slideshow.rs b/crates/ui-egui/src/dialogs/settings/slideshow.rs new file mode 100644 index 000000000..b67427c40 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/slideshow.rs @@ -0,0 +1,120 @@ +use super::display::duration_slider; +use super::widgets::{check, choices, heading, hint, row}; +use super::*; + +/// The Slideshow tab: the slide preview, then the settings, in one column the width of the page +/// (the page scrolls; a column beside the preview would be wider than the window). Without +/// `with_preview` the preview is left out: [`window`] has pinned it above the page. +pub(super) fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, with_preview: bool) { + if with_preview { + crate::panels::slide_layout::preview(app, ui, t, f32::INFINITY); + ui.add_space(6.0); + } + crate::panels::slide_templates::section(app, ui, t); + ui.add_space(6.0); + playback_section(app, ui, t); + ui.add_space(6.0); + crate::panels::slide_layout::sections(app, ui, t); + ui.add_space(6.0); + crate::panels::slide_overlays::sections(app, ui, t); + ui.add_space(6.0); + crate::panels::slide_titles::sections(app, ui, t); + ui.add_space(6.0); + export_section(app, ui, t); +} + +/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). +fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Export")); + let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); + ui.horizontal_wrapped(|ui| { + for (id, label, format) in [ + ("slideshowExportPdf", "Export PDF…", "pdf"), + ("slideshowExportJpeg", "Export JPEG…", "jpeg"), + ("slideshowExportVideo", "Export Video…", "video"), + ] { + let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label))); + register(ui, format!("button:{id}"), r.rect); + if r.clicked() { + let _ = app.run("dialog.slideshowExport", json!({"format": format})); + } + } + }); + hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack.")); +} + +fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; + heading(ui, t, crate::i18n::tr("Playback")); + let s = app.session.slideshow.clone(); + let mut v = s.manual; + if check(ui, "settings.slideshowManual", &mut v, "Manual slideshow") { + let _ = app.run("slideshow.playback", json!({"manual": v})); + } + hint(ui, t, crate::i18n::tr("A manual slideshow shows each slide until → or ← changes it. Exports still use the slide duration.")); + row(ui, t, crate::i18n::tr("Slide duration"), |ui| { + duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) + }); + row(ui, t, crate::i18n::tr("Fade duration"), |ui| { + duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration)) + }); + hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts.")); + row(ui, t, crate::i18n::tr("Fade"), |ui| { + #[derive(Clone, Copy, PartialEq)] + enum Fade { + Cross, + Black, + White, + Color, + } + let mut f = match s.fade_color { + None => Fade::Cross, + Some([0, 0, 0]) => Fade::Black, + Some([255, 255, 255]) => Fade::White, + Some(_) => Fade::Color, + }; + let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")]; + if choices(ui, "settingsSlideshowFade", &opts, &mut f) { + let c = match f { + Fade::Cross => Value::Null, + Fade::Black => json!("#000000"), + Fade::White => json!("#ffffff"), + Fade::Color => json!([64, 64, 64]), + }; + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + if f == Fade::Color + && let Some(mut c) = s.fade_color + { + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui, "button:slideshowFadeColor", r.rect); + if r.changed() { + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + } + }); + let mut v = s.random_order; + if check(ui, "settings.slideshowRandom", &mut v, "Random order") { + let _ = app.run("slideshow.playback", json!({"randomOrder": v})); + } + let mut v = s.repeat; + if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") { + let _ = app.run("slideshow.playback", json!({"repeat": v})); + } + let mut v = s.prepare_previews; + if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") { + let _ = app.run("slideshow.playback", json!({"preparePreviews": v})); + } + hint( + ui, + t, + crate::i18n::tr( + "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).", + ), + ); + hint( + ui, + t, + crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."), + ); +} diff --git a/crates/ui-egui/src/dialogs/settings/widgets.rs b/crates/ui-egui/src/dialogs/settings/widgets.rs new file mode 100644 index 000000000..3aea00b44 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/widgets.rs @@ -0,0 +1,143 @@ +use super::*; + +/// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after +/// either kind of separator, so that its end stays (`…/Pictures/Library`, +/// `…\Pictures\Library`): the end says which folder it is. A last folder too long by itself +/// loses its end ([`crate::widgets::elide_head`]). +pub(super) fn elide_path(path: &str, max: f32, width: impl Fn(&str) -> f32) -> String { + if width(path) <= max { + return path.to_string(); + } + // (a separator at the very end is not where a name starts: `…/` alone would say nothing) + let end = path.trim_end_matches(['/', '\\']).len(); + let mut rest = path; + while let Some(i) = rest.find(['/', '\\']).filter(|i| path.len() - rest.len() + i + 1 < end) { + let separator = if rest.get(i..).is_some_and(|r| r.starts_with('\\')) { '\\' } else { '/' }; + rest = rest.get(i + 1..).unwrap_or(""); + let shorter = format!("…{separator}{rest}"); + if width(&shorter) <= max { + return shorter; + } + } + let name = rest.trim_end_matches(['/', '\\']); + // (nothing but separators: never an empty label) + if name.is_empty() { "…".to_string() } else { crate::widgets::elide_head(name, max, width) } +} + +/// A folder or file path on one line in the room left in its row (`text:{id}`): shortened from +/// the front when it is too long ([`elide_path`]), with all of it in a tooltip then. `after` is +/// the width of what follows it on the line (a button, a note) and its spacing: the path leaves +/// that room, so those never wrap onto a second line. +pub(super) fn path_label(ui: &mut egui::Ui, id: &str, path: &str, font: egui::FontId, color: egui::Color32, after: f32) { + let shown = + elide_path(path, (ui.available_width() - after).max(0.0), |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x); + // (`truncate` is the last resort, for a room narrower than "…") + let r = ui.add(egui::Label::new(RichText::new(&shown).font(font).color(color)).truncate().show_tooltip_when_elided(false)); + register(ui, format!("text:{id}"), r.rect); + if shown != path { + r.on_hover_text(path); + } +} + +/// The width of `text` in the style `style`, and the gap before it: what a label takes in a row. +pub(super) fn label_width(ui: &egui::Ui, text: &str, style: egui::TextStyle) -> f32 { + ui.painter().layout_no_wrap(text.to_string(), style.resolve(ui.style()), egui::Color32::WHITE).size().x + ui.spacing().item_spacing.x +} + +/// The same for a button, which has padding either side of its text. +pub(super) fn button_width(ui: &egui::Ui, text: &str) -> f32 { + label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x +} + +pub(crate) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.add_space(4.0); + ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); +} + +pub(crate) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(LABEL_W); + // (a label longer than its column wraps in it; it does not push the controls along) + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(label)).color(t.text_label)).wrap()); + }); + // The controls wrap in the room beside the label, each further line starting under the + // first control, so a row with many choices grows down and never widens the window. + let wrapped = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), wrapped, |ui| { + ui.spacing_mut().item_spacing.y = 4.0; + add(ui) + }) + .inner + }) + .inner +} + +pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); +} + +/// A checkbox addressable as `check:{id}`; true when toggled. +pub(crate) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { + let r = ui.checkbox(value, crate::i18n::tr(label)); + register(ui, format!("check:{id}"), r.rect); + r.changed() +} + +/// Mutually exclusive buttons (`button:{id}-{index}`). +pub(crate) fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { + let mut changed = false; + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (v, l)) in options.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v { + *value = *v; + changed = true; + } + } + changed +} + +#[cfg(test)] +mod path_tests { + use super::elide_path; + + /// Width = characters, so a limit of 20 is "20 characters". + fn fit(path: &str, max: usize) -> String { + elide_path(path, max as f32, |s| s.chars().count() as f32) + } + + #[test] + fn a_path_that_fits_is_left_alone() { + assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); + assert_eq!(fit("", 5), ""); + } + + #[test] + fn leading_folders_go_first_and_the_end_stays() { + assert_eq!(fit("/Users/me/Pictures/Library", 20), "…/Pictures/Library"); + assert_eq!(fit("/Users/me/Pictures/Library", 10), "…/Library"); + } + + /// Windows paths: `Path::display` gives backslashes there. + #[test] + fn a_path_with_backslashes_keeps_its_end_too() { + assert_eq!(fit(r"C:\Users\me\Pictures\Library", 20), r"…\Pictures\Library"); + assert_eq!(fit(r"C:\Users\me/Pictures\Library", 10), r"…\Library"); + } + + #[test] + fn a_last_folder_too_long_by_itself_loses_its_end() { + assert_eq!(fit("/Users/me/a-very-long-folder-name", 8), "a-very-…"); + assert_eq!(fit("no-separator-at-all", 8), "no-sepa…"); + assert_eq!(fit("/Users/Фотографии-из-отпуска", 8), "Фотогра…"); + } + + #[test] + fn a_separator_at_the_end_is_not_where_the_name_starts() { + assert_eq!(fit("/Users/me/Pictures/Library/", 11), "…/Library/"); + assert_eq!(fit("/Users/me/a-very-long-folder-name/", 8), "a-very-…"); + assert_eq!(fit(r"C:\Users\me\Library\", 11), r"…\Library\"); + assert_eq!(fit("///", 2), "…"); + assert_eq!(fit(r"\\server\share\folder", 15), r"…\share\folder"); + } +} diff --git a/crates/ui-egui/src/slideshow_export.rs b/crates/ui-egui/src/dialogs/slideshow_export.rs similarity index 63% rename from crates/ui-egui/src/slideshow_export.rs rename to crates/ui-egui/src/dialogs/slideshow_export.rs index 62996e421..7a898b5db 100644 --- a/crates/ui-egui/src/slideshow_export.rs +++ b/crates/ui-egui/src/dialogs/slideshow_export.rs @@ -5,6 +5,8 @@ use serde_json::{Value, json}; +use super::frame::{choices, field}; +use super::*; use crate::LightcraftApp; use crate::pick::{PickKind, PickRequest, Picked}; @@ -71,15 +73,73 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, msg); } +/// A labelled row (fixed label column). +/// View ▸ Export Slideshow…: what to write (PDF, JPEG sequence, video) and how big; where comes +/// after (a save dialog). +pub(super) fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { + use lightcraft_engine::slideshow_export::{ExportSettings, Format}; + let t = Tokens::get(ui.ctx()); + let photos = app.session.visible_cloned().len(); + let slide = app.session.slideshow.slide_duration; + let (intro, ending) = app.session.slide_titles.durations(slide); + let titles = usize::from(intro.is_some()) + usize::from(ending.is_some()); + let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0); + let summary = crate::i18n::tr_format!( + "{n} photos in view · {slides} slides · {time}", + n = photos, + slides = photos + titles, + time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64) + ); + ui.label(egui::RichText::new(summary).color(t.text_dim)); + let before = settings.format; + choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format); + if settings.format != before { + *settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) }; + } + match settings.format { + Format::Video => { + choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size); + choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps); + let mut webm = settings.container == "webm"; + choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm); + settings.container = if webm { "webm".into() } else { "mp4".into() }; + } + _ => { + choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size); + } + } + field(ui, "Quality", |ui| { + let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); + crate::widgets::register(ui, "slider:slideshowExportQuality", r.rect); + }); + let note = match settings.format { + Format::Pdf => { + let r = ui.checkbox(&mut settings.full_screen, crate::i18n::tr("Automatically show full screen")); + crate::widgets::register(ui, "check:slideshowExportFullScreen", r.rect); + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode." + } + Format::Jpeg => "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.", + Format::Video => "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.", + }; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Slides use the slideshow's layout, overlays and intro and ending screens, in view order.")) + .color(t.text_dim), + ); +} + #[cfg(test)] mod tests { use super::*; use crate::state::Dialog; + use crate::tests_fixtures::SyntheticLibrary; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; - /// The app with a picker that answers `answer` and remembers what it was asked. - fn app_answering(answer: String) -> (LightcraftApp, Arc>>) { + /// The app, with two real file-backed photos and a picker that answers `answer` and remembers + /// what it was asked. Keep the returned fixture alive until the app is dropped. + fn app_answering(tag: &str, answer: String) -> (SyntheticLibrary, LightcraftApp, Arc>>) { + let files = SyntheticLibrary::with_photos(tag, 2); let asked = Arc::new(Mutex::new(Vec::::new())); let requests = asked.clone(); let services = crate::Services { @@ -91,9 +151,10 @@ mod tests { })), ..Default::default() }; - let mut s = lightcraft_engine::Session::with_demo(); - s.execute("slideshow.playback", &json!({"slideDuration": 1, "fadeDuration": 0.5})).unwrap(); - (LightcraftApp::new(s, services), asked) + let mut app = files.app(); + app.services = services; + app.session.execute("slideshow.playback", &json!({"slideDuration": 1, "fadeDuration": 0.5})).unwrap(); + (files, app, asked) } fn run_dialog(h: &mut crate::headless::Headless) -> Value { @@ -107,7 +168,11 @@ mod tests { if h.app.slideshow_export_reported && h.app.session.slideshow_export.is_some() { break; } - assert!(Instant::now() < deadline, "export did not finish"); + assert!( + Instant::now() < deadline, + "export did not finish: {}", + h.app.session.slideshow_export.as_ref().map_or_else(|| "no export run".into(), |r| r.json().to_string()) + ); std::thread::sleep(Duration::from_millis(5)); } let run = h.app.session.slideshow_export.clone().unwrap(); @@ -122,7 +187,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let path = dir.join("Show.pdf").to_string_lossy().to_string(); - let (mut app, asked) = app_answering(path.clone()); + let (_files, mut app, asked) = app_answering("ssexp", path.clone()); assert!(crate::menus::ui_enabled(&app, "dialog.slideshowExport")); app.run("dialog.slideshowExport", json!({"format": "pdf"})).unwrap(); let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no slideshow export dialog") }; @@ -145,7 +210,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-v-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let path = dir.join("Show.mp4").to_string_lossy().to_string(); - let (mut app, asked) = app_answering(path.clone()); + let (_files, mut app, asked) = app_answering("ssexp-v", path.clone()); app.run("dialog.slideshowExport", json!({"format": "video"})).unwrap(); let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no dialog") }; assert_eq!((settings.size, settings.fps, settings.container.as_str()), (720, 30, "mp4")); @@ -158,7 +223,7 @@ mod tests { assert!(r["sawRow"].as_bool().unwrap(), "the export showed in the activity stack"); assert!(std::path::Path::new(&path).is_file()); // JPEG: a save dialog names the folder (no file type) - let (mut app, asked) = app_answering(dir.join("Frames").to_string_lossy().to_string()); + let (_files_jpeg, mut app, asked) = app_answering("ssexp-jpeg", dir.join("Frames").to_string_lossy().to_string()); app.run("app.slideshowExport", json!({"format": "jpeg", "size": 64})).unwrap(); assert!(asked.lock().unwrap()[0].filter.is_none()); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs new file mode 100644 index 000000000..5e159a0f4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -0,0 +1,188 @@ +//! Software updates, background check at startup, and startup prompt modal. + +use serde_json::Value; + +use crate::LightcraftApp; + +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, Status, Updater}; + +pub(crate) struct Runtime { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, + /// The desktop app turns the check at start on; tests and the web build leave it off. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, + /// The context of the last frame, to wake the UI when a check is queued from a menu callback. + #[cfg(not(target_arch = "wasm32"))] + repaint: Option, + pub(crate) manual_window_open: bool, + pub(crate) status_message: Option, +} + +impl Default for Runtime { + fn default() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::LIGHTCRAFT, env!("CARGO_PKG_VERSION")), + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, + #[cfg(not(target_arch = "wasm32"))] + repaint: None, + manual_window_open: false, + status_message: None, + } + } +} + +impl LightcraftApp { + /// Whether the app asks for a newer release when it starts (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates_rt.startup_check = on; + } +} + +/// The update prompt owns the keyboard while it is open: shortcuts must not reach the library behind it. +pub(crate) fn blocks_input(app: &LightcraftApp) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.updater.prompt_open() + } + #[cfg(target_arch = "wasm32")] + { + let _ = app; + false + } +} + +pub(crate) fn check(app: &mut LightcraftApp) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.check_requested = true; + if let Some(ctx) = &app.updates_rt.repaint { + ctx.request_repaint(); + } + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + app.updates_rt.manual_window_open = true; + app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into()); + Ok(Value::Null) + } +} + +pub(crate) fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let rt = &mut app.updates_rt; + rt.repaint = Some(ctx.clone()); + if std::mem::take(&mut rt.check_requested) { + rt.updater.check_now(ctx); + } else if rt.startup_check { + rt.updater.check_at_start(ctx); + } + rt.updater.tick(ctx); + } + #[cfg(target_arch = "wasm32")] + let _ = (app, ctx); +} + +pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = app.session.unsaved().is_none(); + match app.updates_rt.updater.show_if(ctx, clean) { + Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::None => {} + } + // The window answers Help ▸ Check for Updates, so it follows the updater's status. + let rt = &mut app.updates_rt; + (rt.manual_window_open, rt.status_message) = match rt.updater.status() { + Status::Idle => (false, None), + Status::Checking => (true, Some(crate::i18n::tr("Checking for updates…").to_string())), + Status::UpToDate => ( + true, + Some( + crate::i18n::tr("LightCraft {version} is currently the newest version available.") + .replace("{version}", env!("CARGO_PKG_VERSION")), + ), + ), + Status::Failed(e) => (true, Some(crate::i18n::tr("Could not check for updates:\n{error}").replace("{error}", e))), + }; + } + + if app.updates_rt.manual_window_open { + let mut open = true; + egui::Window::new(crate::i18n::tr("LightCraft Updates")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + if let Some(msg) = &app.updates_rt.status_message { + ui.label(msg); + } + ui.add_space(8.0); + if ui.button(crate::i18n::tr("OK")).clicked() { + app.updates_rt.manual_window_open = false; + } + }); + if !open { + app.updates_rt.manual_window_open = false; + } + } + + // Closing the window dismisses the outcome it showed. + #[cfg(not(target_arch = "wasm32"))] + if !app.updates_rt.manual_window_open { + app.updates_rt.updater.dismiss_status(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_engine::Session; + + #[test] + fn default_runtime_is_idle() { + let rt = Runtime::default(); + assert!(!rt.manual_window_open); + #[cfg(not(target_arch = "wasm32"))] + { + assert!(!rt.startup_check); + assert!(!rt.updater.prompt_open()); + assert_eq!(rt.updater.status(), &Status::Idle); + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn startup_prompt_shows_and_closes() { + let mut app = LightcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let release = craft_update::Release { + tag: "v99.0.0".into(), + version: craft_update::parse_version("99.0.0"), + name: "LightCraft 99.0.0".into(), + html_url: "https://github.com/storytold/lightcraft/releases/tag/v99.0.0".into(), + ..Default::default() + }; + app.updates_rt.updater.offer(release); + + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui.ctx()); + }); + output.textures_delta.clear(); + assert!(app.updates_rt.updater.prompt_open()); + } +} diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index e9f51a56f..f2bc190d7 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -1,7 +1,10 @@ //! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::docking::{ + Action, Column, ColumnLocation, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Group, GroupLocation, Layout, Location, + Node, PanelLimits, PanelState, Permissions, Placement, Side, UiDock, Zone, +}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -33,21 +36,41 @@ impl Panel { Self::Presets => "Presets", } } - fn default_side(self) -> Zone { - match self { - Self::Canvas => Zone::Right, - Self::Library => Zone::Left, - Self::Tools => Zone::Right, - Self::Presets => Zone::Right, - } + /// The dock the panel opens in when it has no recorded place. + fn home_side(self) -> Side { + if self == Self::Library { Side::Start } else { Side::End } } - fn parse(id: &str) -> Result { - Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) + /// The width of the panel's own column in the default workspace. + fn home_width(self) -> f32 { + match self { + Self::Canvas => crate::state::MIN_PHOTO_WIDTH, + Self::Library => crate::state::LEFT_WIDTH.default, + Self::Tools => crate::state::RIGHT_WIDTH.default, + Self::Presets => PRESETS_WIDTH, + } } } +const PRESETS_WIDTH: f32 = 300.0; + +/// The arrangement generation saved as `UiState::docking_version`. Settings without it hold the +/// side panels in split frames; they start again from [`default_workspace`]. +pub const DOCKING_VERSION: u32 = 1; + +/// Drops saved settings' arrangement from before [`DOCKING_VERSION`], with the places of its +/// closed panels, so the app starts from the default workspace; every other setting stays. +/// Returns true when an arrangement was dropped. +pub fn drop_outdated(settings: &mut serde_json::Map) -> bool { + if settings.get("dockingVersion").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(DOCKING_VERSION)) { + return false; + } + settings.insert("dockingVersion".into(), json!(DOCKING_VERSION)); + settings.remove("docking").is_some_and(|docking| !docking.is_null()) +} + fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } + // Panels open and split beside the photo view, but never join it as tabs. + if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { @@ -58,246 +81,162 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The saved arrangement. Closed panels keep their last group, side, size or floating rect. +pub type Workspace = craft_ui::docking::Workspace; + +fn column(panel: Panel, width: f32) -> Column { + Column::new(vec![Group::new(vec![panel], 0)], width) } -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default), - tabs(Panel::Library), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), - tabs(Panel::Tools), - ), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - } +/// Library in a start dock and Tools in an end dock beside the photo. Presets has a column of +/// its own on Tools' photo side rather than a group under Tools: both are tall lists that are +/// read side by side while trying presets, and stacking them would halve both. +fn docked(left: f32, right: f32) -> Workspace { + let start = Node::Dock { side: Side::Start, columns: vec![column(Panel::Library, left)] }; + let end = Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, right), column(Panel::Presets, PRESETS_WIDTH)] }; + Workspace::new(Layout { + root: Some(split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(left), + start, + split(SplitAxis::Horizontal, SplitSize::FixedSecond(right + PRESETS_WIDTH), tabs(Panel::Canvas), end), + )), + floating: Vec::new(), + }) +} + +pub fn default_workspace() -> Workspace { + docked(crate::state::LEFT_WIDTH.default, crate::state::RIGHT_WIDTH.default) +} + +/// Where a panel without a usable recorded place opens: its own column at its dock's window +/// edge, or Presets on the photo side of Tools while Tools is in the end dock. +fn home(workspace: &Workspace, panel: Panel) -> Location { + let column = ColumnLocation { side: panel.home_side(), width: panel.home_width(), iconic: false, iconic_width: None }; + let docked_at = + |other: Panel| workspace.layout.location(&other).ok().and_then(|location| location.group).and_then(|group| group.column).map(|c| c.side); + let beside = (panel == Panel::Presets && docked_at(Panel::Tools) == Some(Side::End)).then_some(Panel::Tools); + Location { + floating: None, + anchor: beside, + placement: if beside.is_some() { Placement::Column { after: true } } else { Placement::Tab { before: None } }, + size: None, + stack: None, + sibling: Vec::new(), + group: Some(GroupLocation { minimized: false, height: None, column: Some(column) }), } } -impl Workspace { - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} - } - } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) - } +fn error(error: DockError) -> String { + error.to_string() +} - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); +/// The shared checks plus LightCraft's own: saved settings cannot float, hide, share or +/// collapse the photo view, nor send a hidden panel back onto it. +pub fn validate(workspace: &Workspace) -> Result<(), String> { + workspace.validate().map_err(error)?; + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + let mut alone = false; + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } => alone |= panels.as_slice() == [Panel::Canvas], + _ => {} } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) } + if !alone { + return Err("The document view must stay docked in a group of its own".into()); + } + if workspace.hidden.iter().any(|(_, location)| { + location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) + }) { + return Err("A hidden panel cannot restore over the document view".into()); + } + Ok(()) +} - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { +fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> { + let mut next = workspace.clone(); + next.apply(action, permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) +} + +/// Shows a panel at its recorded place, else in its dock column ([`home`]). A panel that +/// already has the asked visibility keeps its tab selection. +fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { + if workspace.layout.contains(&panel) == visible { + return Ok(()); + } + if !visible { + return apply(workspace, Action::Close { panel }); + } + validate(workspace)?; + // A recorded place that can't be rebuilt falls back to joining the fallback's tabs. The + // photo view takes no tabs, so that is refused and the panel goes to its home column below. + if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { + let mut next = workspace.clone(); + if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() { + *workspace = next; return Ok(()); } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - next.hidden.retain(|(id, _)| *id != panel); - if let Some(saved) = &mut location - && saved.floating.is_some() - && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) - { - // The other members of a floating group may have been closed too. - saved.anchor = None; - saved.placement = Placement::Tab { before: None }; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) } + let mut next = workspace.clone(); + next.hidden.retain(|(hidden, _)| *hidden != panel); + let location = home(&next, panel); + next.hidden.push((panel, location)); + next.set_visible(panel, true, Some(Panel::Canvas), permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) +} - /// Membership is passive state; an explicit command also exposes the panel's body. - pub fn reveal(&mut self, panel: Panel) -> Result<(), String> { - self.set_visible(panel, true)?; - self.apply(Action::Activate { panel }) +/// Membership is passive state; an explicit command also exposes the panel's body. +fn reveal(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { + set_visible(workspace, panel, true)?; + apply(workspace, Action::Activate { panel })?; + if workspace.layout.panel_state(&panel) != PanelState::Front { + // A minimized window or group opens again; where nothing is minimized the action is + // refused and the layout stays as it is. + let _ = apply(workspace, Action::SetMinimized { panel, minimized: false }); } + Ok(()) +} - pub fn exposed(&self, panel: Panel) -> bool { - if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) { - return true; - } - let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default(); - while let Some(node) = nodes.pop() { - match node { - Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true, - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true, - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - _ => {} - } - } - false - } +pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool { + workspace.layout.panel_state(&panel) == PanelState::Front +} - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); - } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); - } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => return self.reveal(panel), - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) +/// `ui.dock` params, parsed by the shared command vocabulary. +fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> { + validate(workspace)?; + let mut tagged = params.clone(); + let fields = tagged.as_object_mut().ok_or("ui.dock params must be an object")?; + fields.insert("command".into(), json!("ui.dock")); + let command: DockCommand = serde_json::from_value(tagged).map_err(|error| error.to_string())?; + if let DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel })) = command { + return reveal(workspace, panel); } + let mut next = workspace.clone(); + // `ui.dock` operations carry their own rects; only `window.panel.float` reads the space. + command.apply(&mut next, false, &FloatSpace::new(egui::Rect::ZERO), permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) } fn from_ui(app: &crate::LightcraftApp) -> Workspace { - let mut workspace = Workspace::default(); - if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() { - *size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width)); - if let Node::Split { size, .. } = second.as_mut() { - *size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)); - } - } - workspace + docked(crate::state::LEFT_WIDTH.clamp(app.ui.left_width), crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)) } fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> { - workspace.set_visible(Panel::Library, app.ui.left_panel)?; - workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; - workspace.set_visible(Panel::Presets, app.ui.presets) + set_visible(workspace, Panel::Library, app.ui.left_panel)?; + set_visible(workspace, Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; + set_visible(workspace, Panel::Presets, app.ui.presets) } fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { @@ -314,10 +253,12 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { let Ok(location) = workspace.layout.location(&panel) else { continue; }; - let value = match (location.floating, location.placement, location.size) { - (Some([_, _, width, _]), _, _) - | (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) - | (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), + let column = location.group.and_then(|group| group.column); + let value = match (location.floating, column, location.placement, location.size) { + (Some([_, _, width, _]), ..) => Some(width), + (None, Some(column), ..) => Some(column.width), + (None, None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) + | (None, None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), // A vertical split stores height, and the opposite fixed side sizes a sibling. _ => None, }; @@ -328,7 +269,7 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { } pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool { - app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel)) + app.ui.docking.as_ref().is_none_or(|workspace| exposed_in(workspace, panel)) } pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> { @@ -338,6 +279,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result // the sidebar it scrolls, also when it is a tab behind another panel Some(Panel::Library) } else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets" + || id == "view.rightPanel" || id.starts_with("section.") || id.starts_with("tool.") && id != "tool.none") && app.ui.right != crate::state::RightPanel::None @@ -349,7 +291,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result if let Some(panel) = panel { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); sync(&mut workspace, app)?; - workspace.reveal(panel)?; + reveal(&mut workspace, panel)?; cancel_replaced_gesture(app, Some(&workspace)); app.ui.docking = Some(workspace); } @@ -370,7 +312,7 @@ pub(crate) fn cancel_replaced_gesture(app: &crate::LightcraftApp, workspace: Opt _ => true, }; if replaced && let Some(ctx) = &app.tasks.repaint { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("lightcraft-docking")); + craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("lightcraft-docking")); } } @@ -381,13 +323,13 @@ pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> R fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); if reset { - workspace = Workspace::default(); + workspace = default_workspace(); // The usual LightCraft starting workspace shows its current tool beside the photo. - workspace.set_visible(Panel::Library, false)?; - workspace.set_visible(Panel::Presets, false)?; + set_visible(&mut workspace, Panel::Library, false)?; + set_visible(&mut workspace, Panel::Presets, false)?; } else { sync(&mut workspace, app)?; - workspace.command(params)?; + run(&mut workspace, params)?; } if !renderer_action { cancel_replaced_gesture(app, Some(&workspace)); @@ -397,11 +339,15 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren Ok(json!({"docking": app.ui.docking})) } +/// Draw the docked workspace: restore or fall back from an invalid layout, sync panel +/// visibility, render the panels and the current view in the canvas, then apply the docking +/// actions. If the layout changed during the pass, any in-flight dock drag is cancelled and the +/// docking actions it produced are not applied. pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); - if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { - app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), ui.input(|input| input.time) + 8.0, None)); + if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) { + app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), app.tick_now() + 8.0, None)); workspace = from_ui(app); let _ = sync(&mut workspace, app); } @@ -411,12 +357,32 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; + // Free docking: a panel dropped at an empty window edge makes a dock, and the photo takes + // drops on all four sides. + style.new_dock_zones = true; + style.side_edge_zones = false; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); - style.move_label = crate::i18n::tr("Move to group").into(); + style.move_panel_to_label = crate::i18n::tr("Move to group").into(); style.panels_label = crate::i18n::tr("Panels").into(); style.resize_label = crate::i18n::tr("Resize panels").into(); style.resize_window_label = crate::i18n::tr("Resize panel window").into(); + style.move_group_label = crate::i18n::tr("Move tab group").into(); + style.move_group_to_label = crate::i18n::tr("Move group to").into(); + style.float_group_label = crate::i18n::tr("Float tab group").into(); + style.close_group_label = crate::i18n::tr("Close tab group").into(); + style.close_others_label = crate::i18n::tr("Close other panels in group").into(); + style.minimize_label = crate::i18n::tr("Minimize").into(); + style.expand_label = crate::i18n::tr("Expand").into(); + style.move_column_label = crate::i18n::tr("Move panel column").into(); + style.collapse_to_icons_label = crate::i18n::tr("Collapse to Icons").into(); + style.expand_panels_label = crate::i18n::tr("Expand Panels").into(); + style.collapse_column_label = crate::i18n::tr("Collapse column").into(); + style.expand_column_label = crate::i18n::tr("Expand column").into(); + style.auto_collapse_label = crate::i18n::tr("Auto-Collapse Iconic Panels").into(); + style.move_window_label = crate::i18n::tr("Move panel window").into(); + style.close_window_label = crate::i18n::tr("Close panel window").into(); + style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into(); style.background = t.chrome; style.tab_background = t.chrome; style.active_background = t.canvas; @@ -427,7 +393,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; - let output = DockArea::new(egui::Id::new("lightcraft-docking")).show_with_limits( + let output = DockArea::new(egui::Id::unique("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, &style, @@ -439,11 +405,11 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { - crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); + crate::widgets::register(ui, "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { - crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); + crate::widgets::register(ui, "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } Panel::Presets => crate::panels::presets::show(app, ui), @@ -476,7 +442,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let _ = sync(&mut workspace, app); let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { - craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("lightcraft-docking")); + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::unique("lightcraft-docking")); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); @@ -488,19 +454,20 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { .map_err(|error| error.to_string()) .and_then(|action| command_impl(app, false, &json!({"action": action}), true)) { - app.ui.toast = Some((format!("Panel move was not applied: {error}"), ui.input(|input| input.time) + 8.0, None)); + app.ui.toast = Some((format!("Panel move was not applied: {error}"), app.tick_now() + 8.0, None)); } } } -/// Keep the established ui.set width controls connected to the panel's current parent split. +/// Keep the established ui.set width controls connected to the panel's dock column, floating +/// window or parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width); let Some(workspace) = state.docking.as_mut() else { return Ok(()); }; - workspace.validate()?; + validate(workspace)?; for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] { if params.get(key).is_none() { continue; @@ -509,6 +476,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( if let Some(rect) = &mut location.floating { rect[2] = width; } + if let Some(column) = location.group.as_mut().and_then(|group| group.column.as_mut()) { + column.width = width; + continue; + } match location.placement { Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)), Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)), @@ -516,10 +487,14 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( } continue; } - if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) { + if let Some(group) = workspace.layout.floating.iter().find(|group| group.contains(&panel)) { let mut rect = group.rect; rect[2] = width; - workspace.apply(Action::MoveFloating { panel, rect })?; + apply(workspace, Action::MoveFloating { panel, rect })?; + continue; + } + if workspace.layout.location(&panel).is_ok_and(|location| location.group.is_some_and(|group| group.column.is_some())) { + apply(workspace, Action::ResizeColumn { panel, width })?; continue; } let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); @@ -529,7 +504,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let contains = |node: &Node| match node { Node::Tabs { panels, .. } => panels.contains(&panel), Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - Node::Split { .. } => false, + _ => false, }; if *axis == SplitAxis::Horizontal { if contains(first) { @@ -548,7 +523,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( } } if let Some(action) = change { - workspace.apply(action)?; + apply(workspace, action)?; } } Ok(()) @@ -564,6 +539,23 @@ fn panel_limits(panel: &Panel) -> PanelLimits { PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } } +/// The columns of the dock on `side`, from the window edge inwards: width and the panels of +/// each group. +#[cfg(test)] +pub(crate) fn columns(workspace: &Workspace, side: Side) -> Vec<(f32, Vec>)> { + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: dock, columns } if *dock == side => { + return columns.iter().map(|column| (column.width, column.groups.iter().map(|group| group.panels.clone()).collect())).collect(); + } + _ => {} + } + } + Vec::new() +} + #[cfg(test)] mod tests { use super::*; @@ -583,7 +575,7 @@ mod tests { split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), ); let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) }; - Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() } + Workspace::new(Layout { root: Some(root), floating: Vec::new() }) } #[test] @@ -594,23 +586,20 @@ mod tests { .into_iter() .map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) }) .collect(); - app.ui.docking = Some(Workspace { - layout: Layout { - root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), - floating: Vec::new(), - }, - hidden: Vec::new(), - }); + app.ui.docking = Some(Workspace::new(Layout { + root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), + floating: Vec::new(), + })); let undo = app.session.undo.len(); app.run("panel.crop", json!({})).unwrap(); assert_eq!(app.ui.right, crate::state::RightPanel::Crop); let workspace = app.ui.docking.as_ref().unwrap(); - assert!(workspace.exposed(Panel::Tools)); + assert!(exposed_in(workspace, Panel::Tools)); assert_eq!(app.session.undo.len(), undo); let saved = serde_json::to_string(workspace).unwrap(); let reloaded: Workspace = serde_json::from_str(&saved).unwrap(); - reloaded.validate().unwrap(); - assert!(reloaded.exposed(Panel::Tools)); + validate(&reloaded).unwrap(); + assert!(exposed_in(&reloaded, Panel::Tools)); } #[test] @@ -622,7 +611,7 @@ mod tests { (SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false), ] { let workspace = tools_split(axis, size, first); - workspace.validate().unwrap(); + validate(&workspace).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}"); @@ -630,15 +619,15 @@ mod tests { } #[test] - fn legacy_width_publication_reads_own_horizontal_and_floating_widths() { + fn legacy_width_publication_reads_own_split_and_floating_widths() { for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] { let workspace = tools_split(SplitAxis::Horizontal, size, first); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0)); } - let mut workspace = Workspace::default(); - workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); + let mut workspace = default_workspace(); + apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!(app.ui.right_width, 390.0); @@ -646,12 +635,189 @@ mod tests { #[test] fn legacy_width_update_preserves_hidden_top_panel_height() { - let mut workspace = Workspace::default(); - workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); - workspace.set_visible(Panel::Tools, false).unwrap(); + let mut workspace = default_workspace(); + apply(&mut workspace, Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); + set_visible(&mut workspace, Panel::Tools, false).unwrap(); let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size; let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() }; set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap(); assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height); } + + /// Settings written before workspaces had a version: `{layout, hidden}`, a floating + /// window as one group, hidden locations without dock state. + #[test] + fn version_one_workspace_file_loads_and_reopens_its_hidden_panel() { + let v1 = json!({ + "layout": { + "root": {"Split": { + "axis": "Horizontal", + "size": {"FixedFirst": 300.0}, + "first": {"Tabs": {"panels": ["library"], "active": 0}}, + "second": {"Tabs": {"panels": ["canvas"], "active": 0}} + }}, + "floating": [{"panels": ["tools"], "active": 0, "rect": [120.0, 90.0, 320.0, 600.0]}] + }, + "hidden": [["presets", { + "floating": null, + "anchor": "canvas", + "placement": {"Split": "Right"}, + "size": {"FixedSecond": 280.0}, + "stack": null, + "sibling": ["canvas"] + }]] + }); + let mut workspace: Workspace = serde_json::from_value(v1).unwrap(); + validate(&workspace).unwrap(); + assert_eq!(workspace.version, 1); + let window = workspace.layout.floating.first().unwrap(); + assert!(window.contains(&Panel::Tools)); + assert_eq!(window.rect, [120.0, 90.0, 320.0, 600.0]); + assert!(exposed_in(&workspace, Panel::Library)); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + let location = workspace.layout.location(&Panel::Presets).unwrap(); + assert_eq!((location.anchor, location.size), (Some(Panel::Canvas), Some(SplitSize::FixedSecond(280.0)))); + let saved = serde_json::to_value(&workspace).unwrap(); + assert_eq!(saved["version"], 2); + assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape"); + } + + #[test] + fn default_workspace_docks_library_at_the_start_and_tools_and_presets_at_the_end() { + let workspace = default_workspace(); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); + assert_eq!( + columns(&workspace, Side::End), + [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])], + "Presets has its own column on Tools' photo side" + ); + let Some(Node::Split { second, .. }) = &workspace.layout.root else { panic!("{:?}", workspace.layout.root) }; + let Node::Split { first, .. } = &**second else { panic!("{second:?}") }; + assert_eq!(**first, tabs(Panel::Canvas), "the photo sits between the docks"); + assert!(workspace.layout.floating.is_empty()); + let app = app_with_widths(); + let workspace = from_ui(&app); + assert_eq!(columns(&workspace, Side::Start).first().map(|column| column.0), Some(333.0)); + assert_eq!(columns(&workspace, Side::End).first().map(|column| column.0), Some(410.0)); + } + + #[test] + fn dock_column_widths_publish_and_follow_width_controls() { + let mut app = app_with_widths(); + let workspace = docked(300.0, 310.0); + publish_visibility(&mut app, &workspace); + assert_eq!((app.ui.left_width, app.ui.right_width), (300.0, 310.0)); + let mut state = crate::UiState { docking: Some(workspace), left_width: 350.0, right_width: 400.0, ..Default::default() }; + set_visible(state.docking.as_mut().unwrap(), Panel::Library, false).unwrap(); + set_widths(&mut state, &json!({"leftWidth":350.0, "rightWidth":400.0})).unwrap(); + let workspace = state.docking.as_mut().unwrap(); + assert_eq!(columns(workspace, Side::End).first().map(|column| column.0), Some(400.0), "Tools' column"); + set_visible(workspace, Panel::Library, true).unwrap(); + assert_eq!(columns(workspace, Side::Start), [(350.0, vec![vec![Panel::Library]])], "the closed Library's column"); + } + + #[test] + fn a_panel_without_a_recorded_place_opens_in_its_dock() { + let root = split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(300.0), + tabs(Panel::Canvas), + Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, 300.0)] }, + ); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + set_visible(&mut workspace, Panel::Library, true).unwrap(); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); + assert_eq!(columns(&workspace, Side::End), [(300.0, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]); + // Without Tools in the end dock, Presets takes the dock's window edge. + let mut workspace = default_workspace(); + set_visible(&mut workspace, Panel::Presets, false).unwrap(); + workspace.hidden.clear(); + apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + assert_eq!(columns(&workspace, Side::End), [(PRESETS_WIDTH, vec![vec![Panel::Presets]])]); + } + + fn saved_settings(docking: Value, version: Option) -> serde_json::Map { + let ui = crate::UiState { thumb_size: 210.0, filmstrip: false, presets: true, ..Default::default() }; + let mut saved = serde_json::to_value(&ui).unwrap(); + let fields = saved.as_object_mut().unwrap(); + fields.insert("docking".into(), docking); + match version { + Some(version) => fields.insert("dockingVersion".into(), json!(version)), + None => fields.remove("dockingVersion"), + }; + fields.clone() + } + + #[test] + fn a_saved_split_frame_layout_without_a_docking_version_loads_as_the_default_docks() { + let frames = split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(333.0), + tabs(Panel::Library), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(410.0), tabs(Panel::Canvas), tabs(Panel::Tools)), + ); + let frames = Workspace::new(Layout { root: Some(frames), floating: Vec::new() }); + validate(&frames).unwrap(); + for version in [None, Some(0)] { + let mut fields = saved_settings(serde_json::to_value(&frames).unwrap(), version); + assert!(drop_outdated(&mut fields)); + let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); + assert!(ui.docking.is_none()); + assert_eq!(ui.docking_version, DOCKING_VERSION); + assert_eq!((ui.thumb_size, ui.filmstrip, ui.presets), (210.0, false, true), "other settings stay"); + let mut app = app_with_widths(); + app.ui = ui; + command(&mut app, false, &json!({"operation":"activate","panel":"tools"})).unwrap(); + let workspace = app.ui.docking.as_ref().unwrap(); + assert!(!workspace.layout.contains(&Panel::Library), "the Library panel stays closed"); + assert_eq!( + columns(workspace, Side::End), + [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])] + ); + } + } + + #[test] + fn a_saved_dock_layout_round_trips() { + let mut workspace = default_workspace(); + apply(&mut workspace, Action::ResizeColumn { panel: Panel::Tools, width: 333.0 }).unwrap(); + apply( + &mut workspace, + Action::Move { panel: Panel::Presets, anchor: Panel::Library, placement: Placement::Group { below: true, height: None } }, + ) + .unwrap(); + set_visible(&mut workspace, Panel::Library, false).unwrap(); + let mut fields = saved_settings(serde_json::to_value(&workspace).unwrap(), Some(DOCKING_VERSION)); + assert!(!drop_outdated(&mut fields)); + let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); + let loaded = ui.docking.unwrap(); + validate(&loaded).unwrap(); + assert_eq!(loaded, workspace); + assert_eq!(serde_json::to_value(crate::UiState::default()).unwrap()["dockingVersion"], DOCKING_VERSION, "saves write the version"); + } + + #[test] + fn a_panel_whose_saved_place_is_gone_opens_in_its_dock() { + let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + // Presets, the panel Library went back beside, is neither shown nor hidden. + let location: craft_ui::docking::Location = serde_json::from_value(json!({ + "floating": null, + "anchor": "presets", + "placement": {"Split": "Left"}, + "size": {"FixedFirst": 280.0}, + "stack": null, + "sibling": ["presets"] + })) + .unwrap(); + workspace.hidden.push((Panel::Library, location)); + validate(&workspace).unwrap(); + set_visible(&mut workspace, Panel::Library, true).unwrap(); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); + } } diff --git a/crates/ui-egui/src/drop_overlay.rs b/crates/ui-egui/src/drop_overlay.rs index 0e4825988..dfe8262c8 100644 --- a/crates/ui-egui/src/drop_overlay.rs +++ b/crates/ui-egui/src/drop_overlay.rs @@ -32,13 +32,15 @@ fn lines(app: &LightcraftApp, paths: Vec, unknown: bool) -> Vec } let drop = classify(paths); let mut out = Vec::new(); - if !drop.models.is_empty() { - out.push(crate::i18n::tr("Drop to open the face model").to_string()); - } let plural = |n: usize| if n == 1 { "" } else { "s" }; // no library open yet (desktop: the web build's drops import into its session as before): - // photos wait for the library chosen, presets are refused (the drop handler does the same) + // photos wait for the library chosen, presets and models are refused (the drop handler does the same) let waiting = crate::panels::library_problem::holds_drops(app) && !cfg!(target_arch = "wasm32"); + if !drop.models.is_empty() && waiting { + out.push(crate::i18n::tr("Open a library first, then drop the model again").to_string()); + } else if !drop.models.is_empty() { + out.push(crate::i18n::tr("Drop to open the face model").to_string()); + } if !drop.presets.is_empty() && waiting { out.push(crate::i18n::tr("Open a library first, then drop the presets again").to_string()); } else if !drop.presets.is_empty() { @@ -69,6 +71,13 @@ fn lines(app: &LightcraftApp, paths: Vec, unknown: bool) -> Vec /// Veil and card while files are dragged over the window. pub fn show(app: &LightcraftApp, ctx: &Context) { + // This card is an `Order::Tooltip` layer, above the prompt's or blocking library window's + // Foreground layer (and any dialog that holds a no-library window's drops). While input is + // owned the drag can only be background noise: do not paint over or register it, or the card + // would promise a drop the barrier is holding back. + if crate::panels::notices::blocks_input(app) || crate::panels::library_problem::blocks_file_drops(app) { + return; + } let count = ctx.input(|i| i.raw.hovered_files.len()); if count == 0 { return; @@ -79,7 +88,7 @@ pub fn show(app: &LightcraftApp, ctx: &Context) { let lines = lines(app, known.clone(), known.len() < count); let t = Tokens::get(ctx); let screen = ctx.content_rect(); - let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::new("drop-overlay"))); + let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::unique("drop-overlay"))); painter.rect_filled(screen, 0.0, Color32::from_black_alpha(150)); painter.rect_stroke(screen.shrink(10.0), 12.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); let galleys: Vec<_> = lines.into_iter().map(|l| painter.layout(l, t.semibold(18.0), t.text, (screen.width() - 120.0).max(200.0))).collect(); @@ -94,6 +103,6 @@ pub fn show(app: &LightcraftApp, ctx: &Context) { painter.galley(Align2::CENTER_TOP.anchor_size(at, g.size()).min, g, t.text); y += height + 8.0; } - crate::widgets::register(ctx, "dropOverlay", card); + crate::widgets::register_geometry(ctx, "dropOverlay", card); ctx.request_repaint(); } diff --git a/crates/ui-egui/src/focus_scan.rs b/crates/ui-egui/src/focus_scan.rs index de6e292dc..66e8174e3 100644 --- a/crates/ui-egui/src/focus_scan.rs +++ b/crates/ui-egui/src/focus_scan.rs @@ -10,8 +10,7 @@ use crate::LightcraftApp; const BUSY_SECS: f64 = 0.25; /// Called every frame. Idle, it costs a comparison and wakes nothing; reading, it asks again every [`BUSY_SECS`]. -pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); +pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { if now < app.caches.focus_next_pump { return; } diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs index 62132744a..b213963af 100644 --- a/crates/ui-egui/src/frame_rate.rs +++ b/crates/ui-egui/src/frame_rate.rs @@ -43,8 +43,9 @@ struct Hud { logged: Option, } +/// Stable egui data id under which the FPS HUD's per-frame state is stored. fn hud_id() -> egui::Id { - egui::Id::new("fps-hud-state") + egui::Id::unique("fps-hud-state") } /// The display is off: drop its state, so the time it was off never shows as one long frame. @@ -146,7 +147,7 @@ pub fn show(ctx: &egui::Context, app: &LightcraftApp) { let screen = ctx.content_rect(); let width = 300.0; - egui::Area::new(egui::Id::new("fps-hud")) + egui::Area::new(egui::Id::unique("fps-hud")) .order(egui::Order::Tooltip) .fixed_pos(egui::pos2(screen.right() - width - 24.0, screen.top() + 56.0)) .interactable(false) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f3d8d94cb..ab49563cb 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -13,7 +13,6 @@ //! is occluded. use std::collections::HashMap; -use std::sync::Arc; use std::sync::mpsc::Sender; use std::time::{Duration, Instant}; @@ -36,6 +35,10 @@ pub fn viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; native_viewport_pixels(size, pixels_per_point) } +/// Turn a logical viewport size into native pixels for the headless renderer. Both edges and +/// the scale must be finite and positive; each scaled edge is rounded like egui's scissor and +/// must be within `1..=MAX_IMAGE_EDGE`, and the product must fit both the raster and screenshot +/// pixel budgets. Returns a user-facing error instead of clamping. fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> { let [width, height] = size; if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { @@ -50,8 +53,11 @@ fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usiz Ok(pixels as usize) }; let (w, h) = (axis(width)?, axis(height)?); - if w.checked_mul(h).is_none_or(|pixels| pixels > softpaint::MAX_IMAGE_PIXELS) { - return Err(format!("scaled viewport exceeds {} pixels", softpaint::MAX_IMAGE_PIXELS)); + // The rasterizer's limit is not enough: a screenshot is also bounded by the RGBA budget it is + // admitted against, so reject a size `ui.screenshot` would only refuse later. + let max_pixels = softpaint::MAX_IMAGE_PIXELS.min(crate::MAX_SHOT_PIXELS); + if w.checked_mul(h).is_none_or(|pixels| pixels > max_pixels) { + return Err(format!("scaled viewport exceeds {max_pixels} pixels")); } Ok([w, h]) } @@ -156,7 +162,20 @@ impl HeadlessView { self.clipboard.clone_from(text); } } - let commands = out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); + let mut commands = out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); + // 0.37: `ViewportCommand::Screenshot` carries a one-shot callback. A headless host answers + // it here, after the pass it was issued in, exactly once. + let mut shots = Vec::new(); + commands.retain(|command| match command { + ViewportCommand::Screenshot(callback) => { + shots.push(callback.clone()); + false + } + _ => true, + }); + for callback in shots { + callback.complete(std::sync::Arc::new(self.paint(&std::collections::HashMap::new()))); + } // what a desktop host does with a cut, copy or paste request (egui-winit): the event, next frame for c in &commands { match c { @@ -246,6 +265,10 @@ pub struct Headless { pub window_commands: Vec, /// The pointer cursor the last frame asked for (what a real host would show). pub last_cursor: egui::CursorIcon, + /// Test-only hook run after `raw_input_hook` and before `logic`/`ui`, as eframe does: it can + /// create a prompt after the host input was already filtered for the previous frame. + #[cfg(test)] + after_raw_input_hook: Option>, } /// A headless run (`lightcraft-cli snapshot`, tests) is a one-shot process: however it ends, it @@ -286,9 +309,19 @@ impl Headless { window_maximized: false, window_commands: vec![], last_cursor: egui::CursorIcon::Default, + #[cfg(test)] + after_raw_input_hook: None, } } + /// Run `hook` once, between the next frame's [`LightcraftApp::raw_input_hook`] and its + /// `logic`/`ui` call. This is the production order in eframe: a prompt can be created there + /// after the host input was already accepted for the frame. + #[cfg(test)] + pub(crate) fn set_after_raw_input_hook(&mut self, hook: impl FnMut(&mut LightcraftApp) + 'static) { + self.after_raw_input_hook = Some(Box::new(hook)); + } + pub fn frames(&self) -> u64 { self.frames } @@ -315,7 +348,13 @@ impl Headless { raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized); raw.max_texture_side = (self.reported_texture_side != Some(self.max_texture_side)).then_some(self.max_texture_side); self.reported_texture_side = Some(self.max_texture_side); - self.app.raw_input_hook(&mut raw); + // A headless run's timers follow its simulated clock, not the host's wall clock. + self.app.set_simulated_clock(Some(self.time)); + self.app.raw_input_hook(&self.view.ctx, &mut raw); + #[cfg(test)] + if let Some(mut hook) = self.after_raw_input_hook.take() { + hook(&mut self.app); + } let app = &mut self.app; let mut cursor = egui::CursorIcon::Default; let commands = self.view.run(raw, |ui| { @@ -327,11 +366,10 @@ impl Headless { self.time += FRAME_DT; self.frames += 1; for c in commands { + // `ViewportCommand::Screenshot` never reaches here: `HeadlessView::run` answers the + // one-shot callback after the pass it was issued in, so there is exactly one reply. match c { - ViewportCommand::Screenshot(user_data) => { - let image = Arc::new(self.paint()); - self.events.push(egui::Event::Screenshot { viewport_id: ViewportId::ROOT, user_data, image }); - } + ViewportCommand::Screenshot(_) => {} ViewportCommand::InnerSize(size) => { // Viewport commands use current egui points, which include UI zoom. if let Ok(native_size) = resized_viewport([size.x, size.y], self.pixels_per_point, self.view.ctx.zoom_factor()) { @@ -364,6 +402,7 @@ impl Headless { || self.app.session.denoise_busy() || !self.app.tasks.is_empty() || !self.app.synthetic.is_empty() + || !self.app.pending_gestures.is_empty() || !self.events.is_empty() } @@ -502,4524 +541,4 @@ impl crate::render::RenderOffload for RenderGate { } #[cfg(test)] -mod tests { - #[test] - fn detail_offers_ai_denoise_for_raw_photos_and_leads_to_its_settings() { - use crate::state::Dialog; - use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-denoise-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); - // a raw photo (its file need not exist: nothing here reads it) - let id = PhotoId(h.app.session.catalog.photos().map(|p| p.id.0).max().unwrap_or(0) + 1); - let mut raw = Photo::new( - id, - Source::File { path: dir.join("IMG_1.dng").to_string_lossy().into_owned() }, - "IMG_1.dng", - "DNG", - 6000, - 4000, - "2026-10-01T00:00:00", - ); - raw.kind = MediaKind::Raw; - h.app.session.commit("setup", Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - let widgets = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - h.app.ui.open_sections = vec!["detail".to_string()]; - h.settle(SETTLE); - h.step(); - h.step(); - let w = widgets(&mut h); - assert!(w.contains("\"denoise:setup\""), "a raw photo with no model is offered the setup: {w}"); - // One click lands in Settings, with local installation available even without a download offer. - let r = h.request("ui.clickWidget", json!({"id": "denoise:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); - h.settle(SETTLE); - let listed = h.app.session.execute("denoise.models.list", &json!({})).unwrap(); - assert_eq!(listed["models"].as_array().unwrap().len(), lightcraft_denoise::known::all().len(), "{listed}"); - assert!(widgets(&mut h).contains("\"denoise:installFile\"")); - h.app.ui.dialog = None; - // a photo that is not raw: no offer - h.request( - "engine.execute", - json!({"command": "library.select", "params": {"ids": [h.app.session.catalog.photos().find(|p| p.id != id).unwrap().id.0]}}), - t, - ); - h.settle(SETTLE); - h.step(); - h.step(); - assert!(!widgets(&mut h).contains("\"denoise:setup\""), "nothing is offered for a photo denoise does not apply to"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Install from file… with a model the engine rejects says why in the AI Denoise section (#1381), instead of - /// leaving the button looking dead. - #[test] - fn a_rejected_model_file_says_why_in_settings() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-denoise-rejected-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); - // without the runtime the button is disabled (the workspace build turns it on through the desktop app) - if h.app.session.execute("denoise.models.list", &json!({})).unwrap()["runtime"] != true { - let _ = std::fs::remove_dir_all(&dir); - return; - } - // another app's model folder: the .onnx with that app's model.json, and no denoise-model.json - let onnx = dir.join("rawdenoise.onnx"); - std::fs::write(&onnx, b"not a model").unwrap(); - std::fs::write(dir.join("model.json"), br#"{"name": "rawdenoise", "scale": 2}"#).unwrap(); - let picked = onnx.to_string_lossy().into_owned(); - h.app.services.pick_denoise_model = Some(Box::new(move || vec![picked.clone()])); - h.app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() }); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "denoise:installFile"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); - let painted = h.painted_text(); - assert!( - painted - .iter() - .any(|s| s.starts_with("No denoise-model.json beside this .onnx file. The model.json there") && s.ends_with("(see docs/denoise.md)")), - "{painted:?}" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - use super::*; - - /// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a - /// cut, copy or paste request (from a context menu) comes back as that event on the next frame. - #[test] - fn the_headless_host_has_a_clipboard() { - let mut view = HeadlessView::new(); - let raw = || HeadlessView::raw_input(egui::vec2(200.0, 100.0), 1.0, 0.0, vec![]); - view.run(raw(), |ui| ui.ctx().copy_text("Lisbon".into())); - assert_eq!(view.clipboard, "Lisbon"); - for (command, event) in [ - (ViewportCommand::RequestPaste, egui::Event::Paste("Lisbon".into())), - (ViewportCommand::RequestCopy, egui::Event::Copy), - (ViewportCommand::RequestCut, egui::Event::Cut), - ] { - view.run(raw(), |ui| ui.ctx().send_viewport_cmd(command.clone())); - let mut seen = vec![]; - view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); - assert!(seen.contains(&event), "{command:?}: {seen:?}"); - } - // an empty clipboard pastes nothing, as a desktop host does - view.clipboard.clear(); - view.run(raw(), |ui| ui.ctx().send_viewport_cmd(ViewportCommand::RequestPaste)); - let mut seen = vec![]; - view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); - assert!(!seen.iter().any(|e| matches!(e, egui::Event::Paste(_))), "{seen:?}"); - } - - #[test] - fn screenshot_dimensions_are_checked_before_layout() { - assert_eq!(viewport_pixels([1600.0, 1000.0], 2.0).unwrap(), [3200, 2000]); - assert_eq!(viewport_pixels([345.0, 200.0], 0.9).unwrap(), [311, 180]); - assert_eq!(viewport_pixels([8000.0, 8000.0], 1.0).unwrap(), [8000, 8000]); - for (size, scale) in [ - ([f32::NAN, 10.0], 1.0), - ([10.0, f32::INFINITY], 1.0), - ([10.0, 10.0], f32::INFINITY), - ([10.0, 10.0], f32::NAN), - ([10.0, 10.0], 0.0), - ([0.0, 10.0], 1.0), - ([10.0, 10.0], 0.01), - ([9000.0, 9000.0], 1.0), - ([9000.0, 100.0], 2.0), - ] { - assert!(viewport_pixels(size, scale).is_err(), "{size:?} at {scale}"); - } - let raw = HeadlessView::raw_input(egui::vec2(f32::INFINITY, 10.0), 1.0, 0.0, vec![]); - assert_eq!(raw.screen_rect.unwrap().size(), egui::vec2(1600.0, 1000.0)); - } - - #[test] - fn ui_zoom_preserves_host_pixels_and_scales_layout() { - for (size, scale, expected) in - [(egui::vec2(400.0, 240.0), 1.0, [400, 240]), (egui::vec2(345.0, 200.0), 0.9, [311, 180]), (egui::vec2(400.0, 240.0), 2.0, [800, 480])] - { - let mut view = HeadlessView::new(); - for frame in 0..9 { - // the interface scale changes between frames (Cmd+Plus / Cmd+Minus are the - // photo's zoom, issue #566: egui's own keyboard zoom is off) - match frame { - 1 => view.ctx.set_zoom_factor(1.1), - 5 => view.ctx.set_zoom_factor(1.0), - _ => {} - } - let raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]); - view.run(raw, |ui| { - ui.label("UI zoom changes layout, keeping the host viewport size"); - }); - assert_eq!(view.size_px(), expected, "frame {frame} at scale {scale}"); - assert_eq!(view.paint(&HashMap::new()).size, expected); - if frame == 4 { - assert!(view.ctx.zoom_factor() > 1.0); - assert!(view.ctx.content_rect().width() < size.x); - } - if frame == 8 { - assert!(view.ctx.zoom_factor() < 1.01); - } - } - } - } - - #[test] - fn shadow_screenshot_keeps_the_zoomed_hosts_size_and_scale() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], 2.0); - let t = Duration::from_secs(5); - h.view.ctx.set_zoom_factor(1.1); - assert_eq!(h.request("ui.inspect", json!({}), t)["ok"], true); - assert!(h.view.ctx.zoom_factor() > 1.0); - let main = h.view.ctx.clone(); - let screenshot = h.app.headless_screenshot(&main, true).unwrap(); - assert_eq!(screenshot.size, [960, 640]); - let shadow = h.app.shadow.as_ref().unwrap(); - assert!((shadow.ctx.pixels_per_point() - main.pixels_per_point()).abs() < 0.001); - assert!((shadow.ctx.content_rect().width() - main.content_rect().width()).abs() < 0.1); - } - - #[test] - fn control_resize_uses_effective_points_after_ui_zoom() { - for scale in [0.9, 1.0, 2.0] { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], scale); - let t = Duration::from_secs(5); - assert_eq!(h.request("ui.zoomFactor", json!({"factor": 1.1}), t)["ok"], true); - h.step(); - assert!(h.view.ctx.zoom_factor() > 1.0); - let native_size = resized_viewport([320.0, 240.0], scale, h.view.ctx.zoom_factor()).unwrap(); - let expected = native_viewport_pixels([native_size.x, native_size.y], scale).unwrap(); - assert_eq!(h.request("ui.resize", json!({"width": 320, "height": 240}), t)["ok"], true); - assert_eq!(h.view.size_px(), expected); - assert!((h.view.ctx.content_rect().width() - 320.0).abs() < 0.1); - let main = h.view.ctx.clone(); - assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, expected); - } - } - - #[test] - fn control_resize_keeps_a_minimum_width_after_zooming_out() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], 1.0); - h.view.ctx.set_zoom_factor(0.5); - let t = Duration::from_secs(5); - assert_eq!(h.request("ui.resize", json!({"width": 1, "height": 120}), t)["ok"], true); - assert_eq!(h.size, egui::vec2(0.5, 60.0)); - assert_eq!(h.view.size_px(), [1, 60]); - assert!((h.view.ctx.content_rect().width() - 1.0).abs() < 0.01); - let main = h.view.ctx.clone(); - assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [1, 60]); - } - - #[test] - fn control_resize_rounds_in_native_points_after_a_fractional_zoom_out() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], 0.9); - let t = Duration::from_secs(5); - h.view.ctx.set_zoom_factor(0.9); - h.step(); - assert!((h.view.ctx.zoom_factor() - 0.9).abs() < 0.001); - assert_eq!(h.request("ui.resize", json!({"width": 150, "height": 150}), t)["ok"], true); - assert_eq!(h.size, egui::vec2(135.0, 135.0)); - assert_eq!(h.view.size_px(), [122, 122]); - assert!((h.view.ctx.content_rect().width() - 150.0).abs() < 0.01); - let main = h.view.ctx.clone(); - assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [122, 122]); - } - - #[test] - fn invalid_control_resize_preserves_the_headless_viewport() { - let app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - let mut h = Headless::new(app, [480.0, 320.0], 2.0); - h.app.ui.settings.gpu = false; - h.step(); - let size = h.size; - let rejected = h.request("ui.resize", json!({"width": 9000, "height": 8000}), Duration::from_secs(5)); - assert_eq!(rejected["ok"], false, "{rejected}"); - assert_eq!(h.size, size); - for bad in [json!({"width": "320", "height": 240}), json!({"width": 320, "height": null}), json!({"width": true})] { - let rejected = h.request("ui.resize", bad, Duration::from_secs(5)); - assert_eq!(rejected["ok"], false, "{rejected}"); - assert_eq!(h.size, size); - } - let accepted = h.request("ui.resize", json!({"width": 320, "height": 240}), Duration::from_secs(5)); - assert_eq!(accepted["ok"], true, "{accepted}"); - assert_eq!(h.size, egui::vec2(320.0, 240.0)); - assert_eq!(h.view.size_px(), [640, 480]); - } - - /// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out - /// settle would show a half-rendered UI. - const SETTLE: Duration = Duration::from_secs(120); - - fn demo(size: [f32; 2]) -> Headless { - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - Headless::new(app, size, 1.0) - } - - /// Agents cut, copy and paste in a text field as the system does for ⌘X / ⌘C / ⌘V - /// (`ui.clipboard`), and `ui.inspect` → `copied` says what the UI last copied. - #[test] - fn agents_cut_copy_and_paste_in_text_fields() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - r - }; - h.settle(SETTLE); - ask(&mut h, "ui.clickWidget", json!({"id": "field:search"})); - ask(&mut h, "ui.text", json!({"text": "wedding"})); - ask(&mut h, "ui.key", json!({"key": "a", "cmd": true})); - ask(&mut h, "ui.clipboard", json!({"action": "copy"})); - assert_eq!(ask(&mut h, "ui.inspect", json!({}))["result"]["copied"], "wedding"); - ask(&mut h, "ui.clipboard", json!({"action": "cut"})); - assert_eq!(h.app.ui.search, ""); - ask(&mut h, "ui.clipboard", json!({"action": "paste", "text": "travel"})); - assert_eq!(h.app.ui.search, "travel"); - let r = h.request("ui.clipboard", json!({"action": "shred"}), t); - assert_eq!(r["ok"], false, "an unknown action is refused: {r}"); - } - - /// `ui.clipboard` acts in a text field: with none focused it is refused, rather than doing - /// what ⌘V does there on one platform (paste edit settings) and nothing on another. - #[test] - fn the_clipboard_method_needs_a_text_field() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - let undo = h.app.session.undo.len(); - for action in ["copy", "paste"] { - let r = h.request("ui.clipboard", json!({"action": action, "text": "travel"}), t); - assert_eq!(r["ok"], false, "{action}: {r}"); - assert!(r["error"].as_str().is_some_and(|e| e.contains("text field")), "{r}"); - } - h.settle(SETTLE); - assert_eq!(h.app.session.undo.len(), undo, "nothing was pasted"); - } - - /// `ui.clickWidget` right-clicks with `button: "right"`, as `ui.click` does (MCP `click` - /// passes it on with a widget): a filmstrip photo's context menu opens. - #[test] - fn click_widget_right_clicks() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.settle(SETTLE); - let cell = h.app.widgets.iter().find(|(w, _)| w.starts_with("film:")).map(|(w, _)| w.clone()).expect("a filmstrip photo"); - assert!(!egui::Popup::is_any_open(&h.view.ctx)); - let r = h.request("ui.clickWidget", json!({"id": cell, "button": "right"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); - } - - /// #1877: a right-click while another photo's context menu is open closes that menu and - /// opens this photo's, which stays open (it flashed and closed again). - #[test] - fn right_clicking_elsewhere_moves_the_context_menu() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - let thumbs: Vec = h.app.widgets.iter().filter(|(w, _)| w.starts_with("thumb:")).map(|(w, _)| w.clone()).collect(); - let (Some(a), Some(b)) = (thumbs.first().cloned(), thumbs.get(1).cloned()) else { panic!("two grid photos: {thumbs:?}") }; - let popup = |w: &str| { - let id: u64 = w.trim_start_matches("thumb:").parse().expect("a photo id"); - egui::Id::new(("cell", id)).with("popup") - }; - assert!(!egui::Popup::is_any_open(&h.view.ctx)); - let r = h.request("ui.clickWidget", json!({"id": a, "button": "right"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "the first photo's menu is open"); - // A real click: the press and the release reach the UI on different frames, with the - // pointer resting on the photo in between (`ui.clickWidget` sends both on one frame). - let rect = h.app.widgets.iter().find(|(w, _)| *w == b).map(|(_, r)| *r).expect("the second photo on screen"); - let at = egui::pos2(rect.left() + rect.width() * 0.9, rect.center().y); - let button = egui::PointerButton::Secondary; - let modifiers = egui::Modifiers::NONE; - h.app.synthetic.push(egui::Event::PointerMoved(at)); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); - // one frame at a time: the menu flashed for a frame or two before it closed - for frame in 0..6 { - h.step(); - assert!(!egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "frame {frame}: the first photo's menu closed"); - if frame > 0 { - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the second photo's menu is open"); - } - } - h.settle(SETTLE); - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the second photo's menu stays open"); - // The same photo again, elsewhere on it (the menu that is open belongs to this very - // widget): the menu moves to the pointer and stays. - let before = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); - let at = egui::pos2(rect.left() + rect.width() * 0.2, rect.center().y); - h.app.synthetic.push(egui::Event::PointerMoved(at)); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); - for frame in 0..6 { - h.step(); - if frame > 0 { - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the menu is open after a right-click on the same photo"); - } - } - h.settle(SETTLE); - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the menu stays open"); - let after = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); - assert!((after.left() - at.x).abs() < 4.0, "the menu moved to the pointer: {after:?} vs {at:?} (was {before:?})"); - } - - /// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo. - #[test] - fn up_and_down_move_through_the_grid_rows() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "grid"}), t); - h.settle(SETTLE); - let first = h.app.session.visible_cloned()[0]; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first.0]}}), t); - h.settle(SETTLE); - let rect = |h: &Headless| { - let a = h.app.session.selection.active.unwrap(); - h.app.widgets.iter().find(|(w, _)| *w == format!("thumb:{}", a.0)).map(|(_, r)| *r).expect("grid cell") - }; - let top = rect(&h); - h.request("ui.key", json!({"key": "down"}), t); - h.settle(SETTLE); - let below = rect(&h); - let pos = { - let a = h.app.session.selection.active; - h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) - } - .unwrap(); - assert!(below.top() > top.bottom(), "the next row, at {pos}: {top:?} -> {below:?}"); - assert!(below.left() < top.right() && below.right() > top.left(), "overlapping horizontally"); - h.request("ui.key", json!({"key": "up"}), t); - h.settle(SETTLE); - assert_eq!(h.app.session.selection.active, Some(first), "back up"); - h.request("ui.key", json!({"key": "up"}), t); - h.settle(SETTLE); - assert_eq!(h.app.session.selection.active, Some(first), "the top row stays put"); - // the filmstrip (one row): Down is the next photo - h.request("ui.set", json!({"view": "detail"}), t); - h.settle(SETTLE); - h.request("ui.key", json!({"key": "down"}), t); - h.settle(SETTLE); - assert_eq!( - { - let a = h.app.session.selection.active; - h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) - }, - Some(1) - ); - } - - /// The search field is a shared text field: right-click ▸ Paste puts the clipboard's text in - /// and filters by it. - #[test] - fn the_search_field_pastes_from_its_menu() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - h.view.clipboard = "wedding".into(); - for (id, button) in [("field:search", "right"), ("field:search:paste", "left")] { - let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.ui.search, "wedding"); - assert_eq!(h.app.session.filter.text, "wedding", "and the library is filtered by it"); - } - - /// Issue #1648: clearing the filters from the loupe's pill empties the search field too, so - /// the field never shows a search that is no longer applied. - #[test] - fn clearing_the_loupe_filters_empties_the_search_field() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "a"}))] { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.session.filter.text, "a", "filtered while typing"); - assert!(!h.app.session.visible_cloned().is_empty(), "the search finds photos, so the loupe has one to show"); - h.request("engine.execute", json!({"command": "view.detail"}), t); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "loupe:filters"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); - } - - /// Esc in the search field gives back the search from before the edit, and the filter with it. - #[test] - fn escape_in_the_search_field_gives_back_the_search() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "travel"}))] { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.session.filter.text, "travel", "filtered while typing"); - let r = h.request("ui.key", json!({"key": "Escape"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); - } - - #[test] - fn demo_grid_snapshot_has_ui_pixels() { - let t0 = Instant::now(); - let mut h = demo([1200.0, 760.0]); - // settle() can see a quiet spell before the first thumbnails land on a loaded machine - h.step_until(SETTLE, |h| h.app.renderer.thumb_textures() > 0); - let img = h.snapshot(SETTLE); - eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames()); - assert_eq!(img.size, [1200, 760]); - // not blank: many distinct colours - let mut colours: Vec = img.pixels.iter().map(|c| u32::from_le_bytes(c.to_array())).collect(); - colours.sort_unstable(); - colours.dedup(); - assert!(colours.len() > 200, "only {} colours", colours.len()); - // text in the top bar: bright pixels on the dark chrome - let top_h = crate::theme::Tokens::default().top_bar_h as usize; - let bright = img.pixels[..top_h * 1200].iter().filter(|c| c.r() > 150 && c.g() > 150 && c.b() > 150).count(); - assert!(bright > 30, "no text in the top bar ({bright} bright px)"); - // thumbnails arrived (photo textures were drawn) - assert!(h.app.renderer.thumb_textures() > 0); - } - - #[test] - fn snapshots_are_deterministic_and_control_requests_work() { - let shot = || { - let mut h = demo([900.0, 600.0]); - let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10)); - assert_eq!(r["ok"], true, "{r}"); - // Compare only fully settled frames: a timed-out settle would compare half-rendered - // pictures and fail as a misleading pixel diff (seen on loaded CI machines). - assert!(h.settle(Duration::from_secs(300)), "the demo detail view did not settle within 300 s"); - h.paint() - }; - let (a, b) = (shot(), shot()); - assert_eq!(a.size, b.size); - // The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in - // the background), which differ by ≤ 1–2 LSB; anything more is a real difference. - let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count(); - assert_eq!(diff, 0, "{diff} pixels differ between two runs"); - } - - /// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance), - /// Survey (keys hit the active photo only), and auto-advance in Detail. - #[test] - fn compare_survey_and_auto_advance_by_keyboard() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating; - let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag; - let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t); - let r = h.request("engine.execute", json!({"command": "view.compare"}), t); - assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}"); - assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); - // rating applies to the candidate (active) only - let before = rating(&h, vis[0]); - h.request("ui.key", json!({"key": "2"}), t); - assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2)); - // arrows move the candidate; the select stays - h.request("ui.key", json!({"key": "right"}), t); - assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); - h.request("ui.key", json!({"key": "left"}), t); - assert_eq!(h.app.ui.compare, Some((vis[0], vis[1]))); - // Shift+X: reject and advance to the next candidate - h.request("ui.key", json!({"key": "x", "shift": true}), t); - assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject); - assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); - h.request("engine.execute", json!({"command": "compare.swap"}), t); - assert_eq!(h.app.ui.compare, Some((vis[2], vis[0]))); - h.request("engine.execute", json!({"command": "compare.makeSelect"}), t); - assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0])); - // Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t); - let r = h.request("engine.execute", json!({"command": "view.survey"}), t); - assert_eq!(r["result"]["photos"], 3); - h.request("engine.execute", json!({"command": "view.autoAdvance"}), t); - assert!(h.app.ui.auto_advance); - h.request("ui.key", json!({"key": "5"}), t); - h.request("ui.key", json!({"key": "p"}), t); - assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); - assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); - assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); - let img = h.snapshot(SETTLE); - assert_eq!(img.size, [1000, 700]); - // Detail: auto-advance moves to the next photo - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("ui.key", json!({"key": "u"}), t); - assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7])); - // Escape leaves the culling views for Detail - h.request("ui.set", json!({"view": "survey"}), t); - h.request("ui.key", json!({"key": "escape"}), t); - assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); - // let in-flight renders finish: worker threads must not outlive the test process' TLS - h.settle(SETTLE); - } - - /// Naming a face in the loupe: point at an unnamed face, click its "Add name" label, type, Enter. Needs the - /// recognition runtime only for the background indexer, so the naming itself is tested whatever the build. - #[test] - fn naming_a_face_in_the_loupe() { - use crate::state::NameEdit; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.active().unwrap(); - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - // pointing at the box shows the invitation; clicking it opens the name box - h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); - h.step(); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(matches!(&h.app.ui.name_edit, Some(NameEdit { index: 0, .. })), "{:?}", h.app.ui.name_edit); - // Escape closes it without naming anything - let r = h.request("ui.key", json!({"key": "escape"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert!(h.app.ui.name_edit.is_none(), "{:?}", h.app.ui.name_edit); - assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name, None); - // open it again, type a name, Enter: the face is named, in one undo step - h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); - h.step(); - h.step(); - h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); - h.step(); - h.step(); - let undo = h.app.session.undo.len(); - h.request("ui.text", json!({"text": "Ann"}), t); - h.step(); - h.request("ui.key", json!({"key": "enter"}), t); - h.step(); - h.step(); - assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name.as_deref(), Some("Ann")); - assert_eq!(h.app.session.undo.len(), undo + 1); - assert!(h.app.ui.name_edit.is_none()); - } - - /// Adding a face model: the dialog shows the file's terms, the model is installed only once they are - /// accepted, and a file LightCraft cannot use only says why. - #[test] - fn adding_a_face_model_shows_its_terms_and_installs_only_once_accepted() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-facemodel-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - let model = dir.join("Mine.onnx"); - std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); - let open = |h: &mut Headless, path: &std::path::Path| { - h.request("engine.execute", json!({"command": "dialog.faceModel", "params": {"path": path.to_string_lossy()}}), t) - }; - - // the dialog opens for the chosen file, with nothing accepted and nothing installed - assert_eq!(open(&mut h, &model)["ok"], true); - h.settle(SETTLE); - h.step(); - let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; - let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; - assert!(!accepted); - assert_eq!(info["kind"], "draft"); - assert!(!dir.join("models").exists()); - // OK does nothing yet - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); - assert!(!dir.join("models").exists()); - // ticking the box and confirming installs it - let r = h.request("ui.clickWidget", json!({"id": "check:faceModel.accept"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(matches!(&h.app.ui.dialog, Some(Dialog::FaceModel { accepted: true, .. }))); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let listed = h.app.run("faces.models.list", json!({})).unwrap(); - assert!(listed["models"].as_array().unwrap().iter().any(|m| m["installed"] == true && m["known"] == false), "{listed}"); - // installed, in use and recognition on: back in Settings ▸ Faces, where the scan can be watched - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); - assert_eq!(listed["enabled"], true); - h.app.ui.dialog = None; - - // a file that is not a usable model says why and offers no install - let junk = dir.join("junk.onnx"); - std::fs::write(&junk, b"not a model").unwrap(); - assert_eq!(open(&mut h, &junk)["ok"], true); - h.step(); - let Some(Dialog::FaceModel { info, .. }) = h.app.ui.dialog.clone() else { panic!("no dialog") }; - assert_eq!(info["kind"], "unsupported"); - // a path that does not exist is an error, not a dialog - h.app.ui.dialog = None; - assert_eq!(open(&mut h, &dir.join("nope.onnx"))["ok"], false); - assert!(h.app.ui.dialog.is_none()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Photo > Detect Faces without the detector offers the download: the terms first, nothing fetched before they are - /// accepted, and the model's row in Settings has its Download button. - #[test] - fn detect_faces_without_the_detector_offers_the_download_and_fetches_nothing_before_acceptance() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let dir = std::env::temp_dir().join(format!("lc-ui-detector-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - - let r = h.app.run("faces.detect", json!({})); - assert!(r.is_err(), "no detector yet"); - let Some(dlg) = h.app.ui.dialog.clone() else { panic!("the download was not offered") }; - let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; - assert!(!accepted); - assert_eq!((info["download"].as_str(), info["host"].as_str()), (Some("yunet-2023mar"), Some("github.com"))); - assert_eq!(info["model"]["licence"]["name"], "MIT"); - // OK without accepting the terms starts nothing - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); - assert!(h.app.session.face_downloads.snapshot().is_empty()); - assert!(!dir.join("models").join(".downloads").exists()); - assert!(h.app.caches.faces_dl_watch.is_empty()); - - // Settings > Faces shows the detector with a Download button - h.app.ui.dialog = Some(Dialog::Settings { tab: "faces".into() }); - h.step(); - // (the first request switches the widget list on, the next frame fills it) - h.request("ui.widgets", json!({}), Duration::from_secs(10)); - h.step(); - let w = h.request("ui.widgets", json!({"filter": "faces:"}), Duration::from_secs(10)); - assert!(w.to_string().contains("faces:download:yunet-2023mar"), "{w}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Pressing Download in Settings shows the model's terms first and fetches nothing until they are accepted; a build - /// that cannot run recognition models offers no download. The licence dialog replaces Settings, which it was opened from. - #[test] - fn download_shows_the_terms_first_and_fetches_nothing_until_accepted() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-facedl-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "faces"}}), t); - h.settle(SETTLE); - h.step(); - let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; - let r = h.request("ui.clickWidget", json!({"id": "faces:download:sface-2021dec"}), t); - if !runtime { - assert_eq!(r["ok"], false, "a build that cannot run the model offers no download: {r}"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; - let Dialog::FaceModel { path, info, accepted } = &dlg else { panic!("the terms did not replace Settings: {dlg:?}") }; - assert_eq!((info["download"].as_str(), *accepted, path.as_str()), (Some("sface-2021dec"), false, "")); - assert_eq!(info["model"]["licence"]["commercial"], "unknown"); - // OK does nothing until the terms are accepted, and nothing has been fetched - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); - assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); - // cancelling leaves it that way - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert!(h.app.ui.dialog.is_none()); - assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Until face recognition is set up, the People view and the loupe's name box say so and offer the next step: with no - /// model, a button that opens Settings ▸ Faces; with a model installed but recognition off, one that switches it on. - #[test] - fn people_and_the_name_box_offer_to_set_face_recognition_up() { - use crate::state::{Dialog, ViewMode}; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-facesetup-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; - let id = h.app.session.active().unwrap(); - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - let offers = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string().contains("\"faces:setup\""); - - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::People); - if !runtime { - assert!(!offers(&mut h), "a build that cannot run recognition offers nothing"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - // no model: the offer is there, and one click lands in Settings ▸ Faces - assert!(offers(&mut h)); - let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); - h.app.ui.dialog = None; - - // the loupe's name box offers it too, and its button leaves for Settings with the box closed - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); - h.step(); - h.step(); - h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); - h.step(); - h.step(); - assert!(h.app.ui.name_edit.is_some() && offers(&mut h), "the name box offers the setup"); - let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert!(h.app.ui.name_edit.is_none()); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); - h.app.ui.dialog = None; - - // a model installed but recognition off: "Turn on" does it on the spot - let model = dir.join("Mine.onnx"); - std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); - let installed = h.app.run("faces.models.install", json!({"path": model.to_string_lossy(), "acknowledged": true, "activate": false})).unwrap(); - h.app.run("faces.models.select", json!({"id": installed["installed"]["id"]})).unwrap(); - h.app.caches.faces_epoch += 1; - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - assert!(offers(&mut h)); - assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], false); - let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], true); - assert_eq!(h.app.ui.dialog, None, "nothing to go to Settings for"); - h.step(); - assert!(!offers(&mut h), "once it is on the offer is gone"); - h.settle(SETTLE); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A click on a person's card opens their page, which shows only cropped faces (one per face, not per photo); a click - /// on one opens its photo; Back, the People button and Escape return to everyone. - #[test] - fn a_persons_page_shows_their_faces_and_back_returns_to_everyone() { - use crate::state::ViewMode; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); - let face = |x: f64, name: &str| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: Some(name.to_string()), - description: None, - }; - // Jane Doe twice in the first photo and once in the second; John Roe in the third - for (id, regions) in [ - (ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]), - (ids[1], vec![face(0.3, "Jane Doe")]), - (ids[2], vec![face(0.3, "John Roe")]), - ] { - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = regions; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - } - let open_people = |h: &mut Headless| { - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - }; - open_people(&mut h); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); - // the card opens the page: a face tile for each of the three faces, not a tile per photo - let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); - let widgets = h.request("ui.widgets", json!({}), t).to_string(); - for (id, index) in [(ids[0], 0), (ids[0], 1), (ids[1], 0)] { - assert!(widgets.contains(&format!("\"person-face:{}:{index}\"", id.0)), "a tile for each face: {widgets}"); - } - assert!(!widgets.contains(&format!("\"person-face:{}:0\"", ids[2].0)), "someone else's face is not on the page"); - // a face opens its photo in the detail view - h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); - // Escape from that photo goes back to the page, not to the grid - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); - // the People button from a photo shows everyone, with the person last left one click away next to the title - h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); - h.step(); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::Detail); - open_people(&mut h); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "people:last"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); - // a photo reached any other way goes back to the grid on Escape - h.request("engine.execute", json!({"command": "view.detail"}), t); - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "person:back"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.person_page, None); - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None), "Escape goes back to everyone"); - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "John Roe"}}), t); - open_people(&mut h); - assert_eq!(h.app.ui.person_page, None, "the People button shows everyone"); - // a missing or empty name is refused - assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {}}), t)["ok"], false); - assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {"name": " "}}), t)["ok"], false); - } - - /// A person's More grid confirms a score-bounded batch through either ↑ or the focused tile's A key. Both - /// routes use one undo step and leave hidden, lower-score and already named faces alone. - #[test] - fn person_more_confirms_all_above_with_one_undo_step() { - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(7).collect(); - assert_eq!(ids.len(), 7); - let region = |name: Option<&str>| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.55, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: name.map(str::to_string), - description: None, - }; - for (id, name) in ids.iter().zip([Some("Jane Doe"), None, None, None, None, None, Some("John Roe")]) { - let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); - meta.regions = vec![region(name)]; - h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); - } - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); - h.settle(SETTLE); - let page = crate::panels::person::PersonPage { - name: "Jane Doe".into(), - total: 1, - confirmed: vec![crate::panels::person::Face { photo: ids[0].0, index: 0, rect: region(None).rect, score: 0.0 }], - more: ids[1..6] - .iter() - .zip([0.95, 0.80, 0.80, 0.80, 0.60]) - .map(|(id, score)| crate::panels::person::Face { photo: id.0, index: 0, rect: region(None).rect, score }) - .collect(), - ready: true, - pending: 0, - }; - let set_page = |h: &mut Headless| { - h.app.caches.person_page = - Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); - h.step(); - }; - h.app.ui.dismissed_faces.insert((ids[4].0, 0)); - set_page(&mut h); - let bulk_id = format!("more-face-above:{}:0", ids[2].0); - assert!(h.request("ui.widgets", json!({"filter": "more-face-above:"}), t).to_string().contains(&bulk_id)); - let undo = h.app.session.undo.len(); - let r = h.request("ui.clickWidget", json!({"id": bulk_id}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - let names = |h: &Headless| ids.iter().map(|id| h.app.session.catalog.photo(*id).unwrap().meta.regions[0].name.clone()).collect::>(); - assert_eq!( - names(&h), - vec![ - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - None, - None, - Some("John Roe".into()) - ] - ); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.request("engine.execute", json!({"command": "edit.undo"}), t); - h.step(); - assert_eq!(names(&h), vec![Some("Jane Doe".into()), None, None, None, None, None, Some("John Roe".into())]); - // With no target A cannot name a face, even on this page. - h.request("ui.move", json!({"x": 1, "y": 1}), t); - h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new(("more-face-above", ids[2].0, 0_usize)))); - set_page(&mut h); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(names(&h)[1], None); - // Text editing owns A even if a face was the last pointer target. - let search = h.request("ui.clickWidget", json!({"id": "field:search"}), t); - assert_eq!(search["ok"], true, "{search}"); - h.step(); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(names(&h)[1], None); - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - // Hovering a tile is enough for the A key, without clicking or moving keyboard focus there. - let tile_id = format!("more-face:{}:0", ids[2].0); - let rect = h.app.widgets.iter().find(|(id, _)| id == &tile_id).unwrap().1; - h.request("ui.move", json!({"x": rect.center().x, "y": rect.center().y}), t); - h.step(); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(names(&h)[1..4], [Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into())]); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.request("engine.execute", json!({"command": "edit.undo"}), t); - h.step(); - h.request("ui.move", json!({"x": 1, "y": 1}), t); - set_page(&mut h); - // Keyboard focus on the candidate makes the same batch action available without hovering it. - h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::new(("more-face", ids[2].0, 0_usize)))); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!( - names(&h), - vec![ - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - None, - None, - Some("John Roe".into()) - ] - ); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.request("engine.execute", json!({"command": "view.photoGrid"}), t); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(h.app.session.undo.len(), undo + 1, "A outside the person page cannot name more faces"); - } - - /// The People view lists the unnamed faces below the named people: select some (a click each, or all), type a name, - /// press Enter, and they are all named at once, in one undo step; the new person appears among the named. - #[test] - fn unnamed_faces_are_selected_and_named_together() { - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); - let face = |x: f64, name: Option<&str>| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.2, y1: 0.55 }, - kind: lightcraft_meta::RegionKind::Face, - name: name.map(str::to_string), - description: None, - }; - for (id, regions) in [ - (ids[0], vec![face(0.1, Some("Jane Doe")), face(0.5, None)]), - (ids[1], vec![face(0.3, None), face(0.6, None)]), - (ids[2], vec![face(0.3, None)]), - ] { - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = regions; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - } - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - h.step(); - let tiles = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); - // the named person is a card, and the four unnamed faces are tiles below - let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.request("engine.execute", json!({"command": "view.people"}), t); - h.step(); - h.step(); - assert_eq!(tiles(&mut h), 4); - // select two (nothing is named by selecting), and the naming bar appears - for (id, i) in [(ids[1], 0), (ids[2], 0)] { - let r = h.request("ui.clickWidget", json!({"id": format!("unnamed-face:{}:{i}", id.0)}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - } - assert_eq!(h.app.ui.unnamed_selected.len(), 2); - let bar = h.request("ui.widgets", json!({"filter": "unnamed:"}), t).to_string(); - assert!(bar.contains("unnamed:name") && bar.contains("unnamed:clear"), "{bar}"); - assert!(h.request("ui.widgets", json!({"filter": "field:unnamedName"}), t).to_string().contains("field:unnamedName")); - // type a name and press Enter: both faces are named, in one undo step, and the selection is gone - let undo = h.app.session.undo.len(); - h.request("ui.text", json!({"text": "Ann Example"}), t); - h.step(); - h.request("ui.key", json!({"key": "enter"}), t); - h.step(); - h.step(); - let named = |h: &Headless, id: lightcraft_catalog::PhotoId, i: usize| h.app.session.catalog.photo(id).unwrap().meta.regions[i].name.clone(); - assert_eq!((named(&h, ids[1], 0), named(&h, ids[2], 0)), (Some("Ann Example".to_string()), Some("Ann Example".to_string()))); - assert_eq!(named(&h, ids[1], 1), None, "a face that was not selected stays unnamed"); - assert_eq!(h.app.session.undo.len(), undo + 1, "one step for both"); - assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty()); - h.settle(SETTLE); - h.step(); - assert_eq!(tiles(&mut h), 2, "the named faces left the unnamed list"); - assert_eq!(h.app.session.catalog.people().len(), 2, "and the new person is among the named"); - // select all, then clear: nothing is named - h.request("ui.clickWidget", json!({"id": "unnamed:selectAll"}), t); - h.step(); - h.step(); - assert_eq!(h.app.ui.unnamed_selected.len(), 2); - h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); - h.step(); - assert!(h.app.ui.unnamed_selected.is_empty()); - // undo gives the two faces back - h.request("engine.execute", json!({"command": "edit.undo"}), t); - h.step(); - assert_eq!(named(&h, ids[1], 0), None); - } - - /// A double click on an unnamed face opens its photo in the loupe, as in the grid (#1664); a single click still only - /// selects it, and Escape from that photo goes back to the People view. - #[test] - fn double_clicking_an_unnamed_face_opens_its_photo() { - use crate::state::ViewMode; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); - for id in &ids { - let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); - } - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0].0]}}), t); - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - h.step(); - let tile = format!("unnamed-face:{}:0", ids[1].0); - // one click selects the face and stays in People - let r = h.request("ui.clickWidget", json!({"id": tile}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::People, Some(ids[0]))); - assert_eq!(h.app.ui.unnamed_selected.len(), 1); - h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); - // half a second of frames, so egui does not count the next two clicks as the third and fourth of one burst - for _ in 0..30 { - h.step(); - } - // two open the photo the face is in - let r = h.request("ui.clickWidget", json!({"id": tile, "count": 2}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); - assert_eq!(h.app.session.catalog.photo(ids[1]).unwrap().meta.regions[0].name, None, "nothing is named"); - // Escape goes back to the faces, not to the grid - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); - } - - /// The name field under the unnamed faces is a shared text field: right-click ▸ Paste puts the - /// clipboard's name in, and Esc leaves the field and clears the selection, as Clear does. - #[test] - fn the_unnamed_faces_name_field_pastes_and_escape_clears() { - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.catalog.photos().map(|p| p.id).next().unwrap(); - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.view.clipboard = "Wedding Guest".into(); - for (widget, button) in [ - (format!("unnamed-face:{}:0", id.0), "left"), - ("field:unnamedName".to_string(), "left"), - ("field:unnamedName".to_string(), "right"), - ("field:unnamedName:paste".to_string(), "left"), - ] { - let r = h.request("ui.clickWidget", json!({"id": widget, "button": button}), t); - assert_eq!(r["ok"], true, "{widget}: {r}"); - h.step(); - h.step(); - h.step(); - } - assert_eq!(h.app.ui.unnamed_name, "Wedding Guest"); - assert_eq!(h.app.ui.unnamed_selected.len(), 1, "still naming"); - h.request("ui.key", json!({"key": "Escape"}), t); - h.step(); - h.step(); - assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty(), "Esc cleared the selection and the name"); - } - - /// A screenful of faces larger than the picture cache's usual budget (96) is all kept: with a fixed budget the same few - /// tiles were evicted and re-requested every frame and stayed blank. - #[test] - fn a_screenful_of_small_faces_is_not_evicted_and_left_blank() { - use lightcraft_catalog::Op; - let mut h = demo([1500.0, 1000.0]); - let t = Duration::from_secs(20); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).collect(); - // seven unnamed faces in every photo of the demo library - for id in &ids { - let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); - meta.regions = (0..7) - .map(|k| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.05 + 0.12 * k as f64, y0: 0.2, x1: 0.15 + 0.12 * k as f64, y1: 0.45 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }) - .collect(); - h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); - } - // the smallest faces, so a lot of them fit on the screen - h.request("ui.set", json!({"thumbSize": 90.0, "view": "people"}), t); - h.settle(SETTLE); - h.step(); - let drawn = h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); - assert!(drawn > 96, "the test needs more faces on screen than the usual budget, got {drawn}"); - // the pictures are made and kept, well beyond the old budget (a few demo photos share a scene, so two faces can share a - // picture: the count is a little under the number of tiles) - let started = Instant::now(); - while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(60) { - h.step(); - std::thread::sleep(Duration::from_millis(20)); - } - let kept = h.app.renderer.variant_textures(); - assert!(kept > 96 + 30, "{kept} pictures kept for {drawn} faces on screen"); - // and they stay: many more frames, and none is evicted to be asked for again - for _ in 0..30 { - h.step(); - } - assert!(h.app.renderer.variant_textures() >= kept, "{} pictures kept after more frames, {kept} before", h.app.renderer.variant_textures()); - } - - /// `ui.inspect` says how hard the face scan is allowed to work and whether the window counts as in front, so a slow - /// scan can be told from a stuck one. - #[test] - fn inspect_reports_the_face_scan_pace() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - h.step(); - let r = h.request("ui.inspect", json!({}), t); - let scan = &r["result"]["faceScan"]; - assert!(["pause", "light", "normal", "full"].contains(&scan["pace"].as_str().unwrap_or("")), "{scan}"); - assert!(scan["focused"].is_boolean() && scan["pending"].is_u64() && scan["indexed"].is_u64(), "{scan}"); - } - - /// Profile browser: live variant thumbnails, hover previews in the loupe without touching the - /// photo or its history, click applies, the star toggles the favourite. - #[test] - fn profile_browser_previews_on_hover_and_applies_on_click() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - let r = h.request("engine.execute", json!({"command": "panel.profiles"}), t); - assert_eq!(r["result"]["open"], true, "{r}"); - h.settle(SETTLE); - let id = h.app.session.active().unwrap(); - let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); - let before = photo(&h); - h.step_until(SETTLE, |h| h.app.renderer.variant_textures() >= 6); - assert!(h.app.renderer.variant_textures() >= 6, "variant thumbnails rendered: {}", h.app.renderer.variant_textures()); - // hover: the loupe shows the look, nothing is committed - let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - // the hover render can land after a quiet spell on a loaded machine (FreeBSD CI): wait for it - h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid")); - assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); - let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render"); - assert_eq!(hover.photo, id); - let after = photo(&h); - assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()), "hover leaves the photo alone"); - assert_eq!(h.app.session.undo.len(), 0); - // moving away ends the preview - h.request("ui.move", json!({"x": 5, "y": 500}), t); - h.step(); - assert!(h.app.hover_preview.is_none()); - // click applies (one history step); the star toggles the favourite - h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); - assert_eq!(photo(&h).develop.profile.id, "lc.vivid"); - assert_eq!(photo(&h).history.len(), before.history.len() + 1); - h.request("ui.clickWidget", json!({"id": "profileStar:lc.portrait"}), t); - assert_eq!(h.app.session.profile_favorites, ["lc.portrait"]); - assert_eq!(photo(&h).develop.profile.id, "lc.vivid", "the star doesn't apply"); - h.settle(SETTLE); - } - - /// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered. - #[test] - fn hovering_the_applied_profile_drops_the_last_hover() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.profiles"}), t); - h.settle(SETTLE); - h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); - h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t); - h.step(); - assert!(h.app.hover_preview.is_some(), "another profile previews"); - h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); - h.step(); - assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one"); - } - - /// The preset applied to a photo is still marked (and its Amount slider back) after visiting - /// another photo, which shows none (issue #2392); an edit to the photo drops the mark. - #[test] - fn applied_preset_is_marked_again_on_returning_to_the_photo() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.settle(SETTLE); - let a = h.app.session.active().unwrap(); - let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); - let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); - let marked = |h: &Headless| { - let r = h.app.widgets.iter().find(|(id, _)| id == "presetApplied").map(|(_, r)| *r)?; - h.app.widgets.iter().find(|(id, wr)| id.starts_with("preset:") && *wr == r).map(|(id, _)| id.clone()) - }; - let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(marked(&h).as_deref(), Some("preset:lc.warm-glow")); - assert!(has(&h, "slider:presetAmount")); - for (photo, want) in [(b, None), (a, Some("preset:lc.warm-glow"))] { - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(marked(&h).as_deref(), want, "on photo {photo:?}"); - assert_eq!(has(&h, "slider:presetAmount"), want.is_some(), "the Amount slider follows the mark"); - } - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(marked(&h), None, "an edit after the preset drops the mark"); - } - - /// A preset applied while the active photo lies outside the selection changes only the - /// selection, so the active photo isn't marked as having it. - #[test] - fn applied_preset_is_not_marked_on_an_active_photo_outside_the_selection() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.settle(SETTLE); - let a = h.app.session.active().unwrap(); - let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); - h.app.session.selection.ids = vec![b]; - h.app.session.selection.active = Some(a); - let before = h.app.session.develop_of(a).unwrap(); - let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - h.step(); - assert_eq!(h.app.session.active(), Some(a)); - assert!(*h.app.session.develop_of(a).unwrap() == *before, "the active photo wasn't changed"); - assert!(!h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "nor marked"); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [b.0]}}), t); - h.settle(SETTLE); - h.step(); - assert!(h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "the selected photo is"); - } - - /// Presets column: resting on a preset previews it in the loupe without a history entry; - /// thumbnails are variant renders; Create Preset includes only the checked groups. - #[test] - fn preset_hover_previews_and_create_preset_groups() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.settle(SETTLE); - let id = h.app.session.active().unwrap(); - let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); - let before = photo(&h); - h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t); - h.settle(SETTLE); - h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); - assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); - let after = photo(&h); - assert_eq!(after.develop, before.develop); - assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering"); - assert!(h.app.session.undo.is_empty()); - // hovering down across group headers within the presets list retains the preview (no flicker) - h.request("ui.hoverWidget", json!({"id": "presetGroup:Creative"}), t); - h.step(); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); - // hovering another preset switches smoothly to it - h.request("ui.hoverWidget", json!({"id": "preset:lc.warm-glow"}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: Warm Glow")); - // thumbnails: variant textures for the visible presets - h.app.ui.preset_thumbs = true; - h.request("ui.move", json!({"x": 5, "y": 500}), t); - h.settle(SETTLE); - h.step(); - assert!(h.app.hover_preview.is_none()); - assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures()); - // Create Preset with a group checklist: untick Light, keep Effects - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t); - h.app.ui.dialog = Some(crate::state::Dialog::create_preset()); - h.step(); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t); - assert_eq!(r["ok"], true, "{r}"); - match &h.app.ui.dialog { - Some(crate::state::Dialog::CreatePreset { groups, .. }) => { - assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}"); - assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default"); - } - d => panic!("dialog: {d:?}"), - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone(); - assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings); - h.settle(SETTLE); - } - - /// A collection of thousands of presets (a Lightroom library imported whole) lists only the - /// rows in view: a few dozen widgets and thumbnails a frame, not one per preset (every frame - /// built all of them and froze the app for seconds); scrolling brings later rows in. - #[test] - fn presets_panel_with_thousands_of_presets_builds_only_visible_rows() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let many = (0..5000) - .map(|i| lightcraft_develop::Preset { - id: format!("user.big.{i}"), - name: format!("Look {i}"), - group: format!("Big {:02}", i / 500), - settings: json!({"light": {"exposure": (i % 20) as f64 / 10.0}}), - favorite: false, - builtin: false, - }) - .collect(); - assert_eq!(h.app.session.add_presets(many).len(), 5000); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.app.ui.preset_thumbs = true; - h.settle(SETTLE); - h.step(); - let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("preset:")).map(|(w, _)| w.clone()).collect::>(); - let first = rows(&h); - assert!((5..60).contains(&first.len()), "{} rows built", first.len()); - assert!(h.app.renderer.variant_textures() < 60, "{} thumbnails", h.app.renderer.variant_textures()); - assert!(h.app.widgets.iter().any(|(w, _)| w == "presetGroup:Big 09"), "every group header is listed"); - let started = std::time::Instant::now(); - h.step(); - eprintln!("presets panel frame with 5000 presets: {:?}", started.elapsed()); - // scroll far down over the list: later presets come into view - let r = h.request("ui.hoverWidget", json!({"id": first[first.len() / 2]}), t); - assert_eq!(r["ok"], true, "{r}"); - for _ in 0..20 { - h.request("ui.scroll", json!({"dy": -2000}), t); - h.step(); - } - h.step(); - let later = rows(&h); - assert!((5..60).contains(&later.len()), "{} rows built", later.len()); - let deep = later.iter().filter_map(|w| w.strip_prefix("preset:user.big.")).filter_map(|n| n.parse::().ok()).max(); - assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); - } - - /// With `label` and `labels` both set (a saved single-label filter, or an agent), the filter - /// bar lights only the labels that pass both, and a click starts from those, not their union. - #[test] - fn filter_bar_labels_from_label_and_labels() { - use lightcraft_catalog::ColorLabel; - let mut h = demo([1400.0, 800.0]); - let t = Duration::from_secs(10); - h.request("engine.execute", json!({"command": "view.filterBar"}), t); - h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["red", "yellow"]})).unwrap(); - // a frame so the bar is laid out for the new filter before the click - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); - assert_eq!(r["ok"], true, "{r}"); - let f = h.app.session.filter.clone(); - assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Red, ColorLabel::Green])); - // nothing passes both: nothing is lit, so a click shows just that label - h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["yellow"]})).unwrap(); - h.step(); - h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); - let f = h.app.session.filter.clone(); - assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Green])); - h.settle(SETTLE); - } - - /// The filter bar drives `library.filter` and saves the view as a smart album. - #[test] - fn filter_bar_filters_and_saves_a_smart_album() { - let mut h = demo([1400.0, 800.0]); - let t = Duration::from_secs(10); - let all = h.app.session.visible_cloned().len(); - h.request("engine.execute", json!({"command": "view.filterBar"}), t); - assert!(h.app.ui.filter_bar); - for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] { - let r = h.request("ui.clickWidget", json!({"id": w}), t); - assert_eq!(r["ok"], true, "{w}: {r}"); - } - let f = h.app.session.filter.clone(); - assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label"); - let n = h.app.session.visible_cloned().len(); - assert!(n > 0 && n < all); - h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t); - assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. }))); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id; - assert_eq!(h.app.session.catalog.album_count(smart), n); - h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t); - assert_eq!(h.app.session.filter, Default::default()); - assert_eq!(h.app.session.visible_cloned().len(), all); - h.settle(SETTLE); - } - - /// The photo grid shows date headers (registered as `group:` widgets); clicking one - /// selects that day's photos; month headers when zoomed out; none when grouping is off. - #[test] - fn grid_groups_by_capture_date() { - let mut h = demo([1300.0, 800.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - let groups = h.app.session.execute("library.groups", &json!({})).unwrap(); - let first = groups[0].clone(); - let key = first["key"].as_str().unwrap().to_string(); - let r = h.request("ui.clickWidget", json!({"id": format!("group:{key}")}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.selection.ids.len() as u64, first["count"].as_u64().unwrap()); - // zoomed out: month headers - h.app.ui.thumb_size = 120.0; - h.settle(SETTLE); - let vis = h.app.session.visible_cloned(); - let month = h.app.session.catalog.date_runs(&vis, h.app.session.sort.key, lightcraft_catalog::GroupBy::Month); - let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); - assert_eq!(r["ok"], true, "{r}"); - h.request("engine.execute", json!({"command": "library.sort", "params": {"group": "none"}}), t); - let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); - assert_eq!(r["ok"], false, "no headers: {r}"); - h.settle(SETTLE); - } - - /// Keywords: the left-panel tree filters (children included), opens levels, the rename dialog - /// renames library-wide, and the Keywords panel adds a suggestion. - #[test] - fn keyword_list_filters_renames_and_suggests() { - // tall: the demo library's own keywords come first in the list - let mut h = demo([1300.0, 1800.0]); - // Host folders arrive asynchronously above Keywords. Keep this keyword fixture's - // geometry independent of their existence and the background stat timing. - if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { - h.app.ui.hidden_locations.extend( - ["Pictures", "Desktop", "Downloads", ""] - .map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }), - ); - } - let t = Duration::from_secs(10); - let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); - let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10)); - ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]})); - ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]})); - h.request("ui.set", json!({"leftPanel": true}), t); - // clicks land on last frame's layout: let the keyword list settle first (on a loaded machine a - // row could still move, and the click then hit its neighbour, e.g. "sunrise") - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel")); - assert_eq!(h.app.session.visible_cloned().len(), 3); - // open the level, filter by the child - h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.visible_cloned().len(), 2); - h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "travel".into(), to: "trips".into() }); - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy")); - assert_eq!(h.app.session.visible_cloned().len(), 2); - // Keywords panel: suggestions co-occurring with the photo's keywords - h.request("engine.execute", json!({"command": "library.clearFilter"}), t); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t); - ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]})); - h.request("ui.set", json!({"right": "keywords"}), t); - assert!(h.settle(SETTLE), "keyword suggestions did not settle"); - let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string())); - h.settle(SETTLE); - } - - /// Rename Keyword's name field is a shared text field. It opens with the name selected, so - /// typing replaces it, and Return renames; right-click ▸ Paste works too; Esc cancels the - /// dialog and renames nothing. - #[test] - fn the_rename_keyword_field_selects_pastes_and_cancels() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - let first = h.app.session.visible_cloned()[0].0; - h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["travel"]}}), t); - let keywords = |h: &Headless| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); - let open = |h: &mut Headless, name: &str| { - h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: name.into(), to: name.into() }); - h.settle(SETTLE); - }; - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.settle(SETTLE); - }; - // typing replaces the selected name - open(&mut h, "travel"); - ask(&mut h, "ui.text", json!({"text": "trips"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - assert!(keywords(&h).contains(&"trips".to_string()) && !keywords(&h).contains(&"travel".to_string()), "{:?}", keywords(&h)); - assert_eq!(h.app.ui.dialog, None); - // the menu's Paste - open(&mut h, "trips"); - h.view.clipboard = "weddings".into(); - ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName", "button": "right"})); - ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName:paste"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - assert!(keywords(&h).contains(&"weddings".to_string()), "{:?}", keywords(&h)); - // Esc cancels - open(&mut h, "weddings"); - ask(&mut h, "ui.text", json!({"text": "parties"})); - ask(&mut h, "ui.key", json!({"key": "Escape"})); - assert_eq!(h.app.ui.dialog, None, "Esc closed the dialog"); - assert!(keywords(&h).contains(&"weddings".to_string()), "renamed nothing: {:?}", keywords(&h)); - } - - /// Return confirms the one-field dialogs: New Album, Rename Album, New Smart Album, Merge - /// Keywords and a text prompt (here: renaming an album). Their name opens selected, so typing - /// replaces it. Return did nothing: the field took the focus back on every frame, so egui never - /// reported the focus Return gives up. - #[test] - fn return_confirms_the_one_field_dialogs() { - use crate::state::Dialog; - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - let first = h.app.session.visible_cloned()[0].0; - h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["holiday"]}}), t); - let album = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0); - let garden = album(&h, "Garden").expect("the demo's Garden album"); - let confirm = |h: &mut Headless, dialog: Dialog, typed: &str| { - h.app.ui.dialog = Some(dialog); - h.settle(SETTLE); - for (method, params) in [("ui.text", json!({"text": typed})), ("ui.key", json!({"key": "Enter"}))] { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); - }; - confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings"); - assert!(album(&h, "Weddings").is_some(), "New Album"); - confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); - assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); - confirm(&mut h, Dialog::NewSmartAlbum { name: "Smart Album".into(), parent: None }, "Everything"); - assert!(album(&h, "Everything").is_some(), "New Smart Album"); - confirm(&mut h, Dialog::MergeKeywords { from: vec!["holiday".into()], into: String::new() }, "travel"); - let keywords = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); - assert!(keywords.contains(&"travel".to_string()) && !keywords.contains(&"holiday".to_string()), "Merge Keywords: {keywords:?}"); - let prompt = Dialog::TextPrompt { - title: "Rename Album".into(), - hint: "Name".into(), - value: "Flowers".into(), - command: "album.rename".into(), - params: json!({"id": garden}), - key: "name".into(), - }; - confirm(&mut h, prompt, "Botanical"); - assert_eq!(album(&h, "Botanical"), Some(garden), "the text prompt"); - } - - /// The sidebar's file-system checks run on worker threads and add rows when they land, which - /// moves every row below them: `busy()` counts them, so `settle` waits for them before a test - /// reads widget positions (a click aimed at a stale rect hits the neighbouring row). - #[test] - fn settle_waits_for_file_system_checks() { - use std::sync::atomic::{AtomicBool, Ordering}; - static RELEASE: AtomicBool = AtomicBool::new(false); - fn slow_check(_: &str) -> bool { - let t0 = Instant::now(); - while !RELEASE.load(Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) { - std::thread::sleep(Duration::from_millis(1)); - } - true - } - let mut h = demo([800.0, 600.0]); - h.settle(SETTLE); - let check = |h: &mut Headless| { - let raw = HeadlessView::raw_input(h.size, 1.0, 0.0, vec![]); - let mut got = None; - h.view.run(raw, |ui| got = crate::panels::left::fs_cached(ui, "test-slow", "x", f64::INFINITY, slow_check)); - got - }; - assert_eq!(check(&mut h), None, "the answer is worked out off the UI thread"); - assert!(h.busy(), "a pending file-system check is pending work"); - RELEASE.store(true, Ordering::Release); - assert!(h.settle(SETTLE)); - assert!(!h.busy()); - assert_eq!(check(&mut h), Some(true)); - } - - /// Photo > Rename Photos…: the dialog previews and renames the selection. - #[test] - fn rename_dialog_renames_the_selection() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - let vis: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": vis}}), t); - let tree = h.request("ui.menu.tree", json!({}), t); - assert!(tree.to_string().contains("Rename 2 Photos…"), "live menu label"); - h.request("engine.execute", json!({"command": "dialog.rename", "params": {"template": "Trip-{seq:2}", "start": 5}}), t); - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let name = |id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().file_name.clone(); - assert!(name(vis[0]).starts_with("Trip-05."), "{}", name(vis[0])); - assert!(name(vis[1]).starts_with("Trip-06."), "{}", name(vis[1])); - h.settle(SETTLE); - } - - /// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows - /// progress, and reports the result when the files are written. - #[test] - fn export_dialog_keeps_its_width() { - // A dialog row sized as "available width − a guessed button width" made the auto-sized - // window grow a little every frame when the real button was wider (issue #8, Linux). - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.app.services.pick_folder = Some(Box::new(|| None)); - h.app.services.pick_files = Some(Box::new(Vec::new)); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - // the widest variant: watermark graphic and every optional row - if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { - let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() }; - wm.text.clear(); - opts.watermark = Some(wm); - } - let width = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({}), t); - r["result"] - .as_array() - .and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")) - .map(|w| w["rect"][2].as_f64().unwrap()) - .expect("dialog on screen") - }; - for _ in 0..5 { - h.step(); - } - let w0 = width(&mut h); - for _ in 0..60 { - h.step(); - } - let w1 = width(&mut h); - assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}"); - assert!(w1 < 700.0, "{w1}"); - } - - /// With a watermark on, the Export dialog shows the first photo with the watermark on it. - #[test] - fn export_dialog_previews_the_watermark() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - let preview = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({}), t); - r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "image:exportWatermarkPreview")).map(|w| w["rect"].clone()) - }; - h.settle(SETTLE); - assert!(preview(&mut h).is_none(), "no preview without a watermark"); - if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { - opts.watermark = Some(lightcraft_engine::export::Watermark { text: "© LightCraft".into(), ..Default::default() }); - } - let t0 = Instant::now(); - while preview(&mut h).is_none() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - let rect = preview(&mut h).expect("the watermark preview is shown"); - assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); - } - - /// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made - /// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export, - /// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept. - #[test] - fn export_dialog_drops_a_depth_the_format_lacks() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let opened = |h: &mut Headless, last: Value| { - h.app.ui.dialog = None; - h.app.session.last_export = Some(last); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - match &h.app.ui.dialog { - Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(), - _ => panic!("the export dialog is open"), - } - }; - let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16})); - assert_eq!(o.bit_depth, None); - lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export"); - assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32)); - } - - /// Review of #1966: a `path` whose extension names no format exports JPEG here, so its - /// `bitDepth` is checked against JPEG's, not let through to an 8-bit file. - #[test] - fn export_checks_the_depth_of_the_format_a_bare_path_gets() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - std::fs::create_dir_all(&out).unwrap(); - h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - for name in ["output", "output.xyz"] { - let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], false, "{name}: {r}"); - assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}"); - } - assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); - // the format such a path gets is still JPEG - let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], true, "{r}"); - let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path(); - assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG"); - let _ = std::fs::remove_dir_all(&out); - } - - #[test] - fn export_dialog_runs_in_the_background() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let w = written.clone(); - h.app.services.write_shared = Some(std::sync::Arc::new(move |p: &str, _: &[u8]| { - std::thread::sleep(Duration::from_millis(150)); // a slow disk: the UI must not wait for it - w.lock().unwrap().push(p.to_string()); - Ok(()) - })); - let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - h.settle(SETTLE); - if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { - *full_size = false; - *resize = lightcraft_engine::export::Resize::long_edge(64); - *dir = "/lc-test-out".into(); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.export.is_some(), "running in the background"); - let running = h.request("ui.inspect", json!({}), t); - assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]); - // …and as a row in the activity stack (issue #345) - assert_eq!(running["result"]["activity"][0]["kind"], "export", "{}", running["result"]["activity"]); - let t0 = Instant::now(); - while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { - h.step(); - } - assert!(h.app.export.is_none(), "finished"); - let w = written.lock().unwrap().clone(); - assert_eq!(w.len(), 3, "{w:?}"); - assert!(w.iter().all(|p| p.starts_with("/lc-test-out/"))); - let after = h.request("ui.inspect", json!({}), t)["result"].clone(); - assert_eq!(after["activity"], json!([]), "the row is gone"); - let last = after["export"]["last"].clone(); - assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}"); - assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64)); - } - - /// Issue #236: the Export dialog's Add to Apple Photos is there on macOS only; there the - /// background export hands the files it wrote to Photos (a fake runner: tests never start - /// osascript or Photos) and the toast says how it went. - #[test] - fn export_dialog_adds_to_apple_photos_on_macos() { - let mut h = demo([1200.0, 1100.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-photos-dialog-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); - let c = calls.clone(); - h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { - c.lock().unwrap().push(args.to_vec()); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "A/L0/001\nB/L0/001\n".into(), stderr: String::new() }) - })); - let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - h.settle(SETTLE); - let shown = h.request("ui.widgets", json!({"filter": "exportAddToPhotos"}), t)["result"].as_array().map_or(0, Vec::len); - assert_eq!(shown > 0, cfg!(target_os = "macos"), "the option is offered where Photos is"); - if !cfg!(target_os = "macos") { - return; - } - h.request("ui.clickWidget", json!({"id": "exportAddToPhotos"}), t); - h.settle(SETTLE); - if let Some(crate::state::Dialog::Export { opts, full_size, resize, dir, .. }) = &mut h.app.ui.dialog { - assert!(opts.add_to_photos, "the checkbox turns it on"); - opts.photos_album = "Trip “2026”".into(); - *full_size = false; - *resize = lightcraft_engine::export::Resize::long_edge(48); - *dir = out.to_string_lossy().into_owned(); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let t0 = Instant::now(); - while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { - h.step(); - } - let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone(); - let written: Vec = last["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect(); - assert_eq!(written.len(), 2, "{last}"); - assert_eq!(last["applePhotos"]["imported"], 2, "{last}"); - assert_eq!(last["applePhotos"]["album"], "Trip “2026”"); - let args = calls.lock().unwrap().clone(); - assert_eq!(args.len(), 1); - assert_eq!(args[0][args[0].len() - 2..], written[..], "the files written go to Photos"); - let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); - assert!(toast.contains("2 added to Apple Photos album “Trip “2026””"), "{toast}"); - // Export with Previous repeats it - assert_eq!(h.app.session.last_export.as_ref().unwrap()["addToPhotos"], true); - let _ = std::fs::remove_dir_all(&out); - } - - /// Codex review of #643: the progress line while Photos imports was translated on the export's - /// worker thread, whose language is always English. - #[test] - fn the_photos_progress_line_is_in_the_users_language() { - if !cfg!(target_os = "macos") { - return; - } - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-photos-language-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - h.app.ui.language = crate::i18n::Locale::De; - crate::i18n::set_language(crate::i18n::Locale::De); - h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - let (release, held) = std::sync::mpsc::channel::<()>(); - let held = std::sync::Mutex::new(held); - let (entered, photos_asked) = std::sync::mpsc::channel::<()>(); - let entered = std::sync::Mutex::new(entered); - h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { - let _ = entered.lock().unwrap().send(()); - let _ = held.lock().unwrap().recv_timeout(Duration::from_secs(30)); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let ids: Vec = h.app.session.visible_cloned().iter().take(1).map(|p| p.0).collect(); - let params = json!({"ids": ids, "dir": out, "longEdge": 48, "addToPhotos": true, "background": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": params}), t); - assert_eq!(r["ok"], true, "{r}"); - // Photos has been asked, and the window hasn't drawn a frame since (Codex review of #643): - // the export's row already has no ✕, and a cancel is refused rather than half-honoured - photos_asked.recv_timeout(Duration::from_secs(60)).expect("the export asks Photos"); - let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); - assert!(!row.cancellable, "{row:?}"); - assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked"); - let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default(); - assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…")); - // the row says so on the next frame (issue #345) - h.step(); - let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); - assert_eq!((row.detail.as_str(), row.cancellable), ("Wird zu Apple Fotos hinzugefügt…", false), "{row:?}"); - release.send(()).unwrap(); - let t0 = Instant::now(); - while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(90) { - h.step(); - } - let last = h.app.last_export_result.clone().expect("the export reports"); - assert_eq!((last["cancelled"].as_bool(), last["applePhotos"]["imported"].as_u64()), (Some(false), Some(1)), "{last}"); - crate::i18n::set_language(crate::i18n::Locale::En); - let _ = std::fs::remove_dir_all(&out); - } - - /// Issue #236 review: control requests run on the UI thread, which must not wait minutes for - /// Photos. `export.addToPhotos` and `app.export {addToPhotos}` without `background` answer at - /// once with the running job; the app announces the outcome when Photos is done. - #[test] - fn a_slow_photos_import_never_blocks_the_window() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(30); - let out = std::env::temp_dir().join(format!("lc-photos-slow-ui-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - std::fs::create_dir_all(&out).unwrap(); - let file = out.join("a.jpg"); - std::fs::write(&file, b"jpeg").unwrap(); - h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = std::sync::Mutex::new(wait); - h.app.session.apple_photos_background = true; // as the desktop app sets it - h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { - // a regression (Photos on the UI thread) fails the timing below instead of hanging - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - let n = args.len() - args.iter().position(|a| a == lightcraft_engine::apple_photos::MARKER).unwrap() - 2; - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) - })); - let t0 = Instant::now(); - let r = - h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t); - assert!(t0.elapsed() < Duration::from_secs(5), "answered at once: {r}"); - assert_eq!(r["result"]["running"], true, "{r}"); - go.send(()).unwrap(); - while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Apple Photos")) { - assert!(t0.elapsed() < Duration::from_secs(20), "the outcome is announced"); - h.step(); - } - assert_eq!(h.app.ui.toast.as_ref().unwrap().0, "1 added to Apple Photos album “Slow”"); - if cfg!(target_os = "macos") { - let t1 = Instant::now(); - let p = json!({"dir": out.to_string_lossy(), "longEdge": 32, "addToPhotos": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert!(t1.elapsed() < Duration::from_secs(10), "the export answers without waiting for Photos: {r}"); - assert_eq!(r["result"]["applePhotos"]["running"], true, "{r}"); - assert_eq!(r["result"]["applePhotos"]["job"], 2, "{r}"); - go.send(()).unwrap(); - while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("1 added to Apple Photos") && !t.0.contains("Slow")) { - assert!(t1.elapsed() < Duration::from_secs(20), "the outcome is announced"); - h.step(); - } - } - let _ = std::fs::remove_dir_all(&out); - } - - /// Review of #236: a headless run (snapshot scripts) ending while an import it started with - /// `wait: false` is still running waits for it, on every exit path (dropping the `Headless`). - #[test] - fn a_headless_run_waits_for_its_photos_import_before_ending() { - let mut h = demo([800.0, 600.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-photos-headless-drop-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("a.jpg"); - std::fs::write(&file, b"jpeg").unwrap(); - let marker = dir.join("imported"); - let m = marker.clone(); - // Photos "imports" when `go` arrives (at most 20 s): released 0.8 s into the drop below - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = std::sync::Mutex::new(wait); - h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - std::fs::write(&m, b"done").unwrap(); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let r = - h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t); - assert_eq!(r["result"]["running"], true, "{r}"); - assert!(!marker.exists(), "still importing"); - let release = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(800)); - let _ = go.send(()); - }); - drop(h); - assert!(marker.exists(), "the import finished before the run ended"); - release.join().unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Review of #236: a snapshot that ends (an error in its script, say) while its background - /// export is still writing waits through the writing and then the import the export starts. - #[test] - fn a_headless_run_waits_for_its_exports_photos_import() { - if !cfg!(target_os = "macos") { - return; // addToPhotos is refused before exporting elsewhere - } - let mut h = demo([800.0, 600.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-photos-headless-export-drop-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let marker = dir.join("imported"); - h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| { - std::thread::sleep(Duration::from_millis(300)); // a slow disk: still writing at the drop - lightcraft_engine::export::write_file(p, bytes) - })); - let m = marker.clone(); - h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { - std::thread::sleep(Duration::from_millis(500)); - std::fs::write(&m, b"done").unwrap(); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let p = json!({"dir": dir.join("out").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "background": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.session.apple_photos_imports.running().is_some(), "reserved while writing"); - drop(h); - assert!(marker.exists(), "the export's import finished before the run ended"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Review of #236: a background export adding to Photos holds the import from before it - /// writes. While its files are being written, `export.addToPhotos` is refused with the - /// export's job number; the export then adds its files under that job. - #[test] - fn a_background_export_holds_photos_while_it_writes() { - if !cfg!(target_os = "macos") { - return; // addToPhotos is refused before exporting elsewhere - } - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-photos-hold-ui-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - std::fs::create_dir_all(&out).unwrap(); - let other = out.join("other.jpg"); - std::fs::write(&other, b"jpeg").unwrap(); - // the writer waits for `go`: the export is still writing while we try another import - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = std::sync::Mutex::new(wait); - h.app.services.write_shared = Some(Arc::new(move |p: &str, bytes: &[u8]| { - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - lightcraft_engine::export::write_file(p, bytes) - })); - let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); - let c = calls.clone(); - h.app.session.apple_photos_background = true; - h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { - c.lock().unwrap().push(args.to_vec()); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let p = json!({"dir": out.join("export").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "Held", "background": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.export.is_some(), "writing in the background"); - let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [other.to_string_lossy()]}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert!(r["error"].as_str().unwrap().contains("An export is about to add its files to Apple Photos (import 1)"), "{r}"); - go.send(()).unwrap(); - let t0 = Instant::now(); - while h.app.export.is_some() { - assert!(t0.elapsed() < Duration::from_secs(20), "the export finishes"); - h.step(); - } - let last = h.app.last_export_result.clone().unwrap(); - assert_eq!((last["applePhotos"]["job"].as_u64(), last["applePhotos"]["imported"].as_u64()), (Some(1), Some(1)), "{last}"); - let args = calls.lock().unwrap().clone(); - assert_eq!(args.len(), 1, "only the export's files went to Photos"); - assert!(args[0].last().unwrap().contains("export"), "{:?}", args[0].last()); - let _ = std::fs::remove_dir_all(&out); - } - - /// Dragging grid photos onto an album row adds them to the album. - #[test] - fn drag_photos_onto_an_album() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("engine.execute", json!({"command": "view.leftPanel"}), t); - let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Dropped"}}), t); - let album = r["result"]["id"].as_u64().unwrap_or_else(|| panic!("{r}")); - h.settle(SETTLE); - let rect = |h: &mut Headless, id: &str| { - let w = h.request("ui.widgets", json!({"filter": id}), t); - let r = w["result"] - .as_array() - .and_then(|a| a.iter().find(|x| x["id"] == id)) - .map(|x| x["rect"].clone()) - .unwrap_or_else(|| panic!("{id}: {w}")); - let f = |i: usize| r[i].as_f64().unwrap(); - (f(0) + f(2) / 2.0, f(1) + f(3) / 2.0) - }; - let first = h.app.session.visible_cloned()[0].0; - let (x, y) = rect(&mut h, &format!("thumb:{first}")); - let (tx, ty) = rect(&mut h, &format!("source:album:{album}")); - let r = h.request("ui.drag", json!({"x": x, "y": y, "toX": tx, "toY": ty, "steps": 12}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let members = h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.clone(); - assert_eq!(members, vec![lightcraft_catalog::PhotoId(first)]); - assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); - } - - /// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge - /// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos - /// show none of it. - #[test] - fn preview_only_raw_shows_badge_and_notices() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let vis = h.app.session.visible_cloned(); - let (po, other) = (vis[0], vis[1]); - let ph = h.app.session.catalog.photo(po).unwrap().clone(); - h.app - .session - .catalog - .apply(lightcraft_catalog::Op::SetContent { - id: po, - width: ph.width, - height: ph.height, - file_size: ph.file_size, - content_hash: ph.content_hash.clone(), - preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), - preview_checked: lightcraft_catalog::APP_VERSION.into(), - }) - .unwrap(); - h.settle(SETTLE); - let ids = |h: &mut Headless, filter: &str| -> Vec { - let w = h.request("ui.widgets", json!({"filter": filter}), t); - w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect() - }; - let badges = ids(&mut h, "badge:previewOnly:"); - assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge"); - // the loupe and the Edit panel - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - h.app.ui.right = crate::state::RightPanel::Edit; - h.settle(SETTLE); - let n = ids(&mut h, "notice:previewOnly:"); - assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}"); - // with a Reload from Disk button (issue #662) - assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:edit".to_string()]); - // Info - h.app.ui.right = crate::state::RightPanel::Info; - h.settle(SETTLE); - assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string())); - assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:info".to_string()]); - // …which re-reads the file and says what came of it (a demo photo has none to re-read) - let r = h.request("ui.clickWidget", json!({"id": "button:reloadPreviewOnly:info"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.app.ui.toast.clone().map(|x| x.0).as_deref(), Some("This raw variant still can't be decoded")); - // a regular photo: no notice - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t); - h.app.ui.right = crate::state::RightPanel::Edit; - h.settle(SETTLE); - assert!(ids(&mut h, "notice:previewOnly:").is_empty()); - } - - #[test] - fn crop_default_browsing_is_read_only_and_drag_is_one_edit() { - let mut h = demo([1200.0, 800.0]); - let photos: Vec<_> = h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); - h.app.run("library.select", json!({"ids": [photos[0].0]})).unwrap(); - h.app.ui.right = crate::state::RightPanel::Crop; - h.app.run("crop.aspect", json!({"aspect": "1x1"})).unwrap(); - let saved = serde_json::to_value(&h.app.ui).unwrap(); - for &photo in &photos[1..3] { - h.app.run("library.select", json!({"ids": [photo.0]})).unwrap(); - let before = h.app.session.catalog.photo(photo).unwrap().clone(); - let undo = h.app.session.undo.len(); - h.settle(SETTLE); - let after = h.app.session.catalog.photo(photo).unwrap(); - assert_eq!(after.develop, before.develop); - assert_eq!(after.history.len(), before.history.len()); - assert!(!after.is_edited()); - assert_eq!(h.app.session.undo.len(), undo); - assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); - } - let photo = photos[2]; - let before = h.app.session.develop_of(photo).unwrap(); - let undo = h.app.session.undo.len(); - h.app.run("develop.beginInteraction", json!({"label": "Crop"})).unwrap(); - h.app.begin_crop().unwrap(); - h.app.run("crop.drag", json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [0.7, 0.7]})).unwrap(); - h.app.run("develop.endInteraction", json!({})).unwrap(); - assert_eq!(h.app.session.develop_of(photo).unwrap().crop.aspect, Some((100, 100))); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.app.run("edit.undo", json!({})).unwrap(); - assert_eq!(h.app.session.develop_of(photo).unwrap(), before); - h.app.ui = serde_json::from_value(saved).unwrap(); - assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); - h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); - assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); - assert_eq!(h.app.session.develop_of(photo).unwrap(), before); - h.app.ui.crop_default_aspect = Some(json!("original")); - let p = h.app.session.catalog.photo(photo).unwrap(); - assert_eq!(h.app.crop_tool_aspect(), Some((p.width * 100, p.height * 100))); - h.settle(SETTLE); - assert_eq!(h.app.session.develop_of(photo).unwrap(), before); - } - - /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. - #[test] - fn crop_keys() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.crop"}), t); - assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Thirds); - let mask_overlay = h.app.ui.mask_overlay; - h.request("ui.key", json!({"key": "O"}), t); - assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Grid); - h.request("ui.key", json!({"key": "O", "shift": true}), t); - assert_eq!(h.app.ui.crop_overlay_orient, 1); - assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); - // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks - let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); - assert!(locked(&h)); - h.request("ui.key", json!({"key": "A"}), t); - assert!(!locked(&h)); - assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); - h.request("ui.key", json!({"key": "A"}), t); - assert!(locked(&h)); - assert!(!h.app.ui.visualize_spots); - } - - /// Info panel: typing into a field survives frames and is saved when it loses focus; the new - /// accessibility and place fields reach the photo. - #[test] - fn info_fields_keep_typing_and_save() { - let mut h = demo([1300.0, 1000.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - h.settle(SETTLE); - for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] { - let r = h.request("ui.clickWidget", json!({"id": format!("field:{key}")}), t); - assert_eq!(r["ok"], true, "{r}"); - h.request("ui.key", json!({"key": "A", "cmd": true}), t); - h.request("ui.text", json!({"text": text}), t); - h.step(); - h.step(); - // still being edited: not saved yet, not lost either - h.request("ui.key", json!({"key": "Tab"}), t); - h.settle(Duration::from_secs(5)); - } - let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta; - assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt")); - assert_eq!(m.usage_terms, "Editorial use only"); - // the copyright status picker is on the panel too - let w = h.request("ui.widgets", json!({"filter": "copyrightStatus"}), t); - assert!(w["result"].to_string().contains("field:copyrightStatus"), "{w}"); - } - - /// Info panel fields are shared text fields. The caption takes several lines: Return starts a - /// new one and only leaving the field saves it. Esc gives up an edit (the title stays as it - /// was, nothing saved), and right-click ▸ Paste works there too. - #[test] - fn info_fields_take_lines_esc_and_the_menu() { - let mut h = demo([1300.0, 1000.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - h.settle(SETTLE); - let meta = |h: &Headless| h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta.clone(); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - }; - let (title, undo) = (meta(&h).title, h.app.session.undo.len()); - // the caption: two lines, saved on leaving - ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); - ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); - ask(&mut h, "ui.text", json!({"text": "Harbour at dusk"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - ask(&mut h, "ui.text", json!({"text": "Lisbon, 2026"})); - assert_eq!(h.app.session.undo.len(), undo, "Return in the caption saves nothing yet"); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - assert_eq!(meta(&h).caption, "Harbour at dusk\nLisbon, 2026"); - // the title: Esc gives the edit up - let undo = h.app.session.undo.len(); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); - ask(&mut h, "ui.text", json!({"text": " (draft)"})); - ask(&mut h, "ui.key", json!({"key": "Escape"})); - h.settle(SETTLE); - assert_eq!((meta(&h).title, h.app.session.undo.len()), (title, undo), "Esc saved nothing"); - // the menu - h.view.clipboard = "Wedding in Sintra".into(); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); - ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title", "button": "right"})); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title:paste"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - assert_eq!(meta(&h).title, "Wedding in Sintra"); - } - - /// Local: a folder's photos show without joining the library; the breadcrumb, Include - /// subfolders and Add to My Photos work from the grid header. - #[test] - fn browse_a_local_folder() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("inner")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() { - let mut img = img.clone(); - img.data[0][0] = i as u8; // different bytes per file - std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - } - let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count(); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); - // the folder is read in the background: the view switches at once, the photos follow - assert_eq!(r["result"]["scanning"], true, "{r}"); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::Folder); - h.settle(SETTLE); - assert!(h.app.scan.is_none() && h.app.import.is_none()); - assert_eq!(h.app.session.visible_cloned().len(), 1); - assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy"); - // this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried - let id = h.app.session.visible_cloned()[0]; - assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some()); - let done = h.app.renderer.completed; - for _ in 0..30 { - h.step(); - } - assert_eq!(h.app.renderer.completed, done, "no retry loop"); - let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t); - assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}"); - assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.app.session.visible_cloned().len(), 2); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2); - // Truncated ancestors remain clickable and still open the original, unabridged path. - let inner = dir.join("inner"); - h.request("engine.execute", json!({"command": "library.browse", "params": {"path": inner.to_string_lossy()}}), t); - h.settle(SETTLE); - let parent_index = dir.to_string_lossy().split(['/', '\\']).filter(|p| !p.is_empty()).count() - 1; - assert_eq!(h.request("ui.clickWidget", json!({"id": format!("crumb:{parent_index}")}), t)["ok"], true); - h.settle(SETTLE); - // the same folder, whatever the platform's separators (issue #538) - assert_eq!(std::path::Path::new(&h.app.session.browse.as_ref().unwrap().path), dir.as_path()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// 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 - /// open down to the hidden folder and push the restore row out of view. - #[test] - fn local_location_can_be_hidden_and_restored() { - let size = [1300.0, 900.0]; - let mut h = demo(size); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id())); - 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"); - 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}"))); - } - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::remove_dir_all(&dir2); - } - - /// Local: a kept folder stays a root while its subfolders (and other locations) are - /// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and - /// the kept folders survive a save/load of the UI state (a restart). - #[test] - fn kept_local_root_stays_while_browsing_below_and_elsewhere() { - let mut h = demo([1300.0, 1400.0]); - let t = Duration::from_secs(10); - let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); - // joined by component: the sidebar's ids spell paths with the platform's separator - let year = base.join("Photos").join("2026"); - let (photos, day1, day2, other) = (s(base.join("Photos")), s(year.join("20260101")), s(year.join("20260114")), s(base.join("Other"))); - h.hide_home_above(&base); - let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); - let rects = |h: &mut Headless| -> std::collections::HashMap { - let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t); - w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect() - }; - h.request("ui.set", json!({"leftPanel": true}), t); - // Home may also own this temporary directory. Keep the fixture tree - // independent of the user's real folders and their async listings. - if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { - assert_eq!(exec(&mut h, "local.hide", json!({"path": home}))["ok"], true); - } - assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true); - assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1)); - exec(&mut h, "library.browse", json!({"path": day1})); - h.settle(SETTLE); - h.step(); - let row = format!("source:local:{day2}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the sibling listing did not arrive"); - let r = rects(&mut h); - assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}"); - assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}"); - let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]); - assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})"); - assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed"); - // another location: the kept root stays listed - assert_eq!(exec(&mut h, "library.browse", json!({"path": other}))["ok"], true); - h.settle(SETTLE); - h.step(); - let row = format!("source:local:{other}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the other location did not arrive"); - let r = rects(&mut h); - assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}"); - // restart: the kept roots come back with the saved UI state - let saved = serde_json::to_string(&h.app.ui).unwrap(); - let back: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(back.local_roots, vec![photos.clone()]); - let _ = std::fs::remove_dir_all(&base); - h.settle(SETTLE); - } - - /// Versions panel: resting on a version previews it in the loupe without changing the photo; - /// a click restores it. - #[test] - fn versions_preview_on_hover_and_restore_on_click() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); - h.request("ui.set", json!({"view": "detail", "right": "versions"}), t); - exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0}})); - exec(&mut h, "version.create", json!({"name": "Bright"})); - exec(&mut h, "develop.set", json!({"values": {"light.exposure": -1.0}})); - h.settle(SETTLE); - let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - assert_eq!(h.request("ui.hoverWidget", json!({"id": "version:0"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.settings.light.exposure), Some(1.0)); - assert_eq!(exposure(&h), -1.0, "hovering changes nothing"); - assert_eq!(h.request("ui.clickWidget", json!({"id": "version:0"}), t)["ok"], true); - h.settle(Duration::from_secs(5)); - assert_eq!(exposure(&h), 1.0); - } - - /// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo. - #[test] - fn slider_nudge_and_pick_advance() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true); - h.request("ui.key", json!({"key": "Up"}), t); - h.request("ui.key", json!({"key": "Up", "shift": true}), t); - assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h)); - h.request("ui.key", json!({"key": "Down"}), t); - assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h)); - let first = h.app.session.active().unwrap(); - h.request("ui.key", json!({"key": "Z", "shift": true}), t); - assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick); - assert_ne!(h.app.session.active(), Some(first), "advanced"); - } - - /// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc - /// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star). - #[test] - fn slider_values_can_be_typed() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - let active = h.app.session.active().unwrap(); - let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating; - let (undo0, rating0) = (h.app.session.undo.len(), rating(&h)); - assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); - h.request("ui.text", json!({"text": "1,5"}), t); - h.request("ui.key", json!({"key": "Enter"}), t); - assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); - assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); - assert_eq!(rating(&h), rating0, "typing 1 set no rating"); - // Esc keeps the value - assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); - h.request("ui.text", json!({"text": "-2"}), t); - h.request("ui.key", json!({"key": "Escape"}), t); - assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); - // something that isn't a number changes nothing - assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); - h.request("ui.text", json!({"text": "bright"}), t); - h.request("ui.key", json!({"key": "Enter"}), t); - assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); - assert_eq!(h.app.session.undo.len(), undo0 + 1); - } - - /// Reset takes precedence over the value being typed when its button is clicked. - #[test] - fn reset_button_wins_over_an_active_numeric_draft() { - let mut h = demo([960.0, 600.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{method}: {r}"); - r["result"].clone() - }; - ask(&mut h, "engine.execute", json!({"command": "app.gpu", "params": {"enabled": false}})); - ask(&mut h, "ui.set", json!({"settings": {"gpu": false, "previewEdge": 1280}, "presets": false})); - ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [1], "active": 1}})); - ask( - &mut h, - "ui.set", - json!({"view": "detail", "right": "edit", "leftPanel": false, "rightWidth": 330, "openSections": ["light"], "histogram": false}), - ); - let active = h.app.session.active().unwrap(); - for (text, terminal) in [("0.75", "Enter"), ("-1,5", "Escape")] { - ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); - ask(&mut h, "ui.text", json!({"text": text})); - ask(&mut h, "ui.key", json!({"key": terminal})); - let value = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); - assert_eq!(value["light"]["exposure"].as_f64(), Some(0.75), "{terminal}: {value}"); - } - ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); - ask(&mut h, "ui.text", json!({"text": "2.0"})); - assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(crate::text_field::id("sliderField:light.exposure"))); - assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderField:light.exposure")); - assert!(h.painted_text().iter().any(|text| text == "2.0"), "the replacement draft must be visible before Reset"); - assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.75, "the draft is not committed yet"); - ask(&mut h, "ui.clickWidget", json!({"id": "button:reset"})); - let reset = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); - assert_eq!( - reset["light"]["exposure"].as_f64(), - Some(0.0), - "Reset must win over the active numeric draft: {reset}; history={:?}; last_inverse={:?}; focus={:?}", - h.app.session.undo.iter().map(|entry| entry.label.as_str()).collect::>(), - h.app.session.undo.last().map(|entry| &entry.op), - h.view.ctx.memory(|m| m.focused()), - ); - } - - /// A slider's typed value is a shared text field: right-click ▸ Paste replaces the value - /// (selected when the field opens) and Return applies it; the menu doesn't close the field. - #[test] - fn a_slider_value_pastes_from_its_menu() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - h.view.clipboard = "0.75".into(); - for (id, button) in - [("sliderValue:light.exposure", "left"), ("sliderField:light.exposure", "right"), ("sliderField:light.exposure:paste", "left")] - { - let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - h.settle(SETTLE); - } - h.request("ui.key", json!({"key": "Enter"}), t); - h.settle(SETTLE); - let exposure = h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - assert!((exposure - 0.75).abs() < 1e-9, "{exposure}"); - } - - /// The eye on a section header switches the section off and on again, one undo step each - /// (issue #316). - #[test] - fn section_eye_switches_a_section_off() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - let light_on = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().section_enabled("light"); - for expected in [false, true] { - // the eye shows while the pointer is on the header - assert_eq!(h.request("ui.hoverWidget", json!({"id": "section:light"}), t)["ok"], true); - assert_eq!(h.request("ui.clickWidget", json!({"id": "sectionEye:light"}), t)["ok"], true); - assert_eq!(light_on(&h), expected); - } - assert!(h.app.ui.section_open("light"), "the click didn't fold the section"); - } - - /// Return commits a tool panel back to Edit; elsewhere it does nothing. - #[test] - fn return_commits_the_crop_tool() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.crop"}), t); - assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop); - h.request("ui.key", json!({"key": "Enter"}), t); - assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit); - h.request("ui.key", json!({"key": "Enter"}), t); - assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools"); - } - - /// ⌘Q (File → Quit LightCraft) closes the window. - #[test] - fn cmd_q_quits() { - let mut h = demo([900.0, 600.0]); - assert!(!h.quit_requested()); - h.request("ui.key", json!({"key": "Q", "cmd": true}), Duration::from_secs(10)); - h.step(); - h.step(); - assert!(h.quit_requested()); - } - - /// File → Import from Folder… opens the import review for a folder (searched recursively). - #[test] - fn add_folder_opens_the_import_review() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-addfolder-{}", std::process::id())); - let sub = dir.join("day 2"); - std::fs::create_dir_all(&sub).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let png = lightcraft_engine::export::encode_image( - &img, - &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, - ) - .unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - std::fs::write(sub.join("b.png"), &png).unwrap(); - let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; - assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too"); - assert!(!opts.copy, "a folder is added in place by default"); - // the review names its source; scanning doesn't save a Local location - assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]); - let name = dir.file_name().unwrap().to_string_lossy().to_string(); - assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)")); - let r = h.request("ui.widgets", json!({}), t); - assert!(r.to_string().contains("label:importSource"), "source shown"); - assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved"); - // The source facts were cached by a worker with the source-language default. They must - // still be rendered in German after switching, with the folder name kept verbatim. - assert_eq!(h.request("engine.execute", json!({"command": "app.language.german"}), t)["ok"], true); - h.settle(SETTLE); - fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { - match shape { - egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()), - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)), - _ => {} - } - } - let mut painted = Vec::new(); - for shape in &h.view.shapes { - texts(&shape.shape, &mut painted); - } - assert!(painted.iter().any(|text| text == &format!("Ordner „{name}“ (und seine Unterordner)")), "{painted:?}"); - for label in ["Quelle", "Übertragen", "Stichwörter", "Vorgabe"] { - assert!(painted.iter().any(|text| text == label), "{label}: {painted:?}"); - } - assert_eq!(h.request("engine.execute", json!({"command": "app.language.english"}), t)["ok"], true); - // a camera / card folder: copied into the library by default - h.app.ui.dialog = None; - let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert!(opts.copy); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it - /// narrower or taller, though the tabs hold different amounts: the window is one size on every tab. - #[test] - fn settings_tabs_keep_the_window_in_place() { - let mut h = demo([1300.0, 820.0]); - let t = Duration::from_secs(10); - let window = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == "dialog:window").map(|(_, r)| *r).expect("the dialog"); - let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let first = window(&h); - let mut heights = Vec::new(); - for tab in ["display", "import", "performance", "general", "interface", "display"] { - h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); - h.settle(SETTLE); - let now = window(&h); - assert!((now.top() - first.top()).abs() < 1.0, "{tab}: top {} → {}", first.top(), now.top()); - assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); - heights.push(now.height()); - } - assert!(heights.iter().all(|h| (h - first.height()).abs() < 1.0), "the tabs keep the height of {}: {heights:?}", first.height()); - } - - /// Settings → Import → "Show the import review when a camera or card is connected": a card that - /// was already in doesn't ask, a new one opens Import from Device (copying), and a busy window - /// asks once it's free. - #[test] - fn a_connected_card_opens_the_import_review() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let root = std::env::temp_dir().join(format!("lc-card-{}", std::process::id())); - let dcim = root.join("DCIM/100NIKON"); - std::fs::create_dir_all(&dcim).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dcim.join("DSC_0001.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let card = lightcraft_engine::devices::Device { - name: "NIKON Z 8".into(), - path: root.join("DCIM").to_string_lossy().to_string(), - root: root.to_string_lossy().to_string(), - }; - h.app.ui.dialog = None; - // first look: the card was already in - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_none() && h.app.ui.dialog.is_none(), "a card that was already in doesn't ask"); - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_none()); - // taken out, then put back while another dialog is open: it waits - crate::import::cards_seen(&mut h.app, Vec::new()); - h.app.ui.dialog = Some(crate::state::Dialog::About); - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_none(), "a busy window isn't interrupted"); - h.app.ui.dialog = None; - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_some(), "the card is scanned once the window is free"); - assert!(h.step_until(t, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert!(opts.copy, "a card is copied into the library"); - // with the option off nothing is remembered, so turning it on again starts with a first look - assert!(!h.app.session.import_defaults.show_on_card); - h.step(); - assert!(h.app.ui.known_cards.is_none()); - let _ = std::fs::remove_dir_all(&root); - } - - /// The folder scan runs in the background: the request returns at once and the review opens - /// when the scan finishes (a folder on a network share must not freeze the window). - /// Clicking the folder being read again keeps the running read (and its progress). - #[test] - fn browsing_the_same_folder_again_does_not_restart() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-browse-again-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (g, n) = (gate.clone(), started.clone()); - h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { - n.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - while !g.load(std::sync::atomic::Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(5)); - } - Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) - })); - let browse = - |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(browse(&mut h)["result"]["scanning"], true); - std::thread::sleep(Duration::from_millis(200)); - assert_eq!(browse(&mut h)["result"]["scanning"], true); - std::thread::sleep(Duration::from_millis(200)); - assert_eq!(started.load(std::sync::atomic::Ordering::Relaxed), 1, "the second click must not start another read"); - gate.store(true, std::sync::atomic::Ordering::Relaxed); - h.settle(SETTLE); - assert_eq!(h.app.session.visible_cloned().len(), 1); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Browsing a folder from the UI (read on a worker thread) records it as browsed, as `library.browse` does: - /// untouched Local photos are only forgotten from folders not browsed for the set number of days. - #[test] - fn browsing_from_the_ui_records_the_folder() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-browse-stamp-{}", std::process::id())); - let day = dir.join("2026/10/14"); - std::fs::create_dir_all(&day).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(day.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let root = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); - let day_s = day.to_string_lossy().to_string(); - assert!(h.app.session.catalog.last_browsed(&root).is_none()); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": root, "subfolders": true}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.app.session.visible_cloned().len(), 1); - assert!(h.app.session.catalog.last_browsed(&root).is_some(), "the browsed folder"); - assert!(h.app.session.catalog.last_browsed(&day_s).is_some(), "the folder its photo is in"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by - /// another app, as `library.browse` does (issue #1392). - #[test] - fn browsing_again_from_the_ui_forgets_deleted_files() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-browse-gone-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - let png = lightcraft_engine::export::encode_image(&img, &o).unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - std::fs::write(dir.join("b.png"), &png).unwrap(); - let path = dir.to_string_lossy().to_string(); - let browse = |h: &mut Headless| { - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - }; - browse(&mut h); - assert_eq!(h.app.session.visible_cloned().len(), 2); - // another app deletes the photo that isn't selected (a selected or open one stays, shown as missing) - let held: Vec = - h.app.session.selection.ids.iter().copied().chain(h.app.session.selection.active).chain(h.app.session.previous_active).collect(); - let name = |id: &lightcraft_catalog::PhotoId| h.app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default(); - let gone = if held.iter().any(|id| name(id) == "b.png") { "a.png" } else { "b.png" }; - std::fs::remove_file(dir.join(gone)).unwrap(); - // a later session: the browse's "Add 2 Photos" step is no longer in the undo history (photos it refers to - // are kept, like selected ones) - h.app.session.undo.clear(); - h.app.session.redo.clear(); - // the folder clicked again in the sidebar - browse(&mut h); - assert_eq!(h.app.session.visible_cloned().len(), 1, "the deleted file's record is forgotten"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn add_folder_scans_in_the_background() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-scanbg-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let png = lightcraft_engine::export::encode_image( - &img, - &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, - ) - .unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - assert!(h.app.scan.is_some() || h.app.ui.dialog.is_some()); - h.settle(SETTLE); - assert!(h.app.scan.is_none()); - assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Cancel closes the scan at once and opens no review; Add from Device while a scan runs is - /// refused and leaves the running scan's options alone. - #[test] - fn folder_scan_cancel_and_busy() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-scancancel-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let png = lightcraft_engine::export::encode_image( - &img, - &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, - ) - .unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - // a scan that can't finish until the test lets it: the probe waits on a flag - let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let g = gate.clone(); - h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { - while !g.load(std::sync::atomic::Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(5)); - } - Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) - })); - let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": dir.to_string_lossy()}}), t); - assert_ne!(r["ok"], true, "a second scan is refused: {r}"); - assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options"); - let r = h.request("ui.inspect", json!({}), t); - assert!(r["result"]["scan"].is_object(), "{r}"); - // Cancel is the ✕ of the scan's row in the activity stack (shown once it is half a second old) - let id = h.app.session.activity.list().first().map(|t| t.id).expect("a scan row"); - let cross = format!("activity:cancel:{id}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); - let r = h.request("ui.clickWidget", json!({"id": cross}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(h.app.scan.is_none(), "✕ closes the scan at once"); - gate.store(true, std::sync::atomic::Ordering::Relaxed); - h.settle(SETTLE); - assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group - /// leaves it alone. ⌘F puts typing into the search field. - #[test] - fn paste_selected_settings_and_find() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); - exec(&mut h, "library.select", json!({"ids": [ids[0]]})); - exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}})); - exec(&mut h, "develop.copy", json!({"groups": ["light", "color"]})); - exec(&mut h, "library.select", json!({"ids": [ids[1]]})); - h.request("ui.key", json!({"key": "V", "cmd": true, "shift": true}), t); - h.settle(SETTLE); - let Some(crate::state::Dialog::PasteSettings { groups }) = &h.app.ui.dialog else { panic!("dialog not open: {:?}", h.app.ui.dialog) }; - assert_eq!(groups, &["light", "color"]); - let r = h.request("ui.clickWidget", json!({"id": "pasteGroup:color"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.request("ui.dialog.confirm", json!({}), t)["ok"], true); - let d = h.app.session.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); - assert_eq!((d.light.exposure, d.color.vibrance), (1.0, 0.0)); - // ⌘F, then typing filters the grid by text - h.request("ui.set", json!({"view": "grid"}), t); - h.request("ui.key", json!({"key": "F", "cmd": true}), t); - h.settle(SETTLE); - h.request("ui.text", json!({"text": "zz-no-such-photo"}), t); - h.settle(SETTLE); - assert_eq!(h.app.ui.search, "zz-no-such-photo"); - assert!(h.app.session.visible_cloned().is_empty()); - } - - /// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos. - #[test] - fn capture_time_dialog_shifts_time_zone() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.visible_cloned()[0]; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); - // The dock header leaves less vertical space; bring the metadata action into view. - let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; - let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; - assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}"); - h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t); - h.request("ui.scroll", json!({"dy": -160.0}), t); - h.settle(SETTLE); - let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; - assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}"); - let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t); - assert_eq!(r["ok"], true, "{r}"); - let r = h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); - assert_eq!(r["ok"], true, "{r}"); - if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog { - *zone = -3.0; - } else { - panic!("dialog not open: {:?}", h.app.ui.dialog); - } - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let after = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); - let secs = lightcraft_catalog::dates::iso_seconds; - assert_eq!(secs(&after).unwrap() - secs(&before).unwrap(), -3 * 3600); - h.settle(SETTLE); - } - - /// Colour labels: Info-panel swatches set/clear the label, the names dialog renames them and - /// the Photo menu shows the names. - #[test] - fn label_swatches_and_names() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.visible_cloned()[0]; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - let r = h.request("ui.clickWidget", json!({"id": "label:green"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.catalog.photo(id).unwrap().label, Some(lightcraft_catalog::ColorLabel::Green)); - h.request("engine.execute", json!({"command": "dialog.labelNames"}), t); - if let Some(crate::state::Dialog::LabelNames { names, .. }) = &mut h.app.ui.dialog { - names[2] = "Approved".into(); - } else { - panic!("no dialog"); - } - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let tree = h.request("ui.menu.tree", json!({}), t).to_string(); - assert!(tree.contains("Approved (Green)"), "menu shows the name"); - h.request("ui.clickWidget", json!({"id": "label:green"}), t); - assert_eq!(h.app.session.catalog.photo(id).unwrap().label, None, "clicking the current label clears it"); - h.settle(SETTLE); - } - - /// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is - /// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album - /// with keywords, as one undo step. - #[test] - fn import_review_dialog() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..5u8 { - let (w, h) = (40usize, 30usize); - let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 40, (k / w * 8) as u8, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); - } - std::fs::copy(dir.join("img0.png"), dir.join("img0-copy.png")).unwrap(); - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let n0 = h.app.session.catalog.len(); - let undo0 = h.app.session.undo.len(); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - h.step_until(SETTLE, |h| h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.candidates.len(), 6); - assert_eq!(opts.candidates.iter().filter(|c| c.duplicate.is_some()).count(), 1); - assert!(h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5, "thumbnails"); - // uncheck the first photo; name a new album and keywords - let r = h.request("ui.clickWidget", json!({"id": "import:0"}), t); - assert_eq!(r["ok"], true, "{r}"); - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - assert_eq!(opts.selected_paths().len(), 4, "duplicate and unchecked photo left out"); - opts.new_album = "Card 1".into(); - opts.keywords = "cardone, 2026".into(); - } else { - panic!("no import dialog"); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - assert_eq!(h.app.session.catalog.len(), n0 + 4); - let album = h.app.session.catalog.albums().find(|a| a.name == "Card 1").expect("album").id; - assert_eq!(h.app.session.catalog.album_count(album), 4); - assert!(h.app.session.catalog.photos().filter(|p| p.meta.keywords.contains(&"cardone".to_string())).count() == 4); - assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); - h.request("engine.execute", json!({"command": "edit.undo"}), t); - assert_eq!(h.app.session.catalog.len(), n0); - h.settle(SETTLE); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The import review offers what to do with files that are in Recently Deleted (issues #298, - /// #706): they are checked, to be restored, by default; Leave them unchecks them; confirming - /// restores the photo (with its edits) or, with Import as new, imports the file afresh. - #[test] - fn import_review_offers_recently_deleted_files() { - for (choice, tag) in [("restore", "r"), ("fresh", "f")] { - let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..2u8 { - let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; - let png = - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); - } - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let r = - h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); - let id = r["result"]["imported"][0].as_u64().unwrap(); - h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); - h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.on_deleted, "restore", "the default brings a deleted photo back"); - assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked from the start: it will be restored"); - opts.set_on_deleted(""); - assert_eq!(opts.selected_paths().len(), 1, "Leave them: only the new file"); - opts.set_on_deleted(choice); - assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked again once a choice is made"); - // a per-cell choice survives switching between the two options, and "Leave them" unchecks - let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap(); - opts.checked[trashed] = false; - opts.set_on_deleted(if choice == "restore" { "fresh" } else { "restore" }); - assert_eq!(opts.selected_paths().len(), 1, "still unchecked"); - opts.set_on_deleted(""); - assert_eq!(opts.selected_paths().len(), 1); - opts.set_on_deleted(choice); - assert_eq!(opts.selected_paths().len(), 2); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - // frames until the import has finished, so its (timed) toast is read before it expires - let t0 = std::time::Instant::now(); - while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - assert!(h.app.import.is_none(), "finished"); - let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); - let photos = &h.app.session.catalog; - assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2, "{choice}"); - let old = photos.photo(lightcraft_catalog::PhotoId(id)); - if choice == "restore" { - assert!(toast.contains("1 restored"), "{toast}"); - assert!(old.is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); - assert_eq!(photos.len(), 2); - } else { - assert!(old.is_none(), "the trashed record is gone"); - assert_eq!(photos.len(), 2); - } - let _ = std::fs::remove_dir_all(&dir); - } - } - - /// With "Leave them", a file in Recently Deleted is left unchecked but a click on its checkbox - /// (or on its "In Recently Deleted" badge) still checks it, and importing it restores the - /// photo (issue #1285). - #[test] - fn import_review_checks_a_recently_deleted_file_by_hand() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-click-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..2u8 { - let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); - } - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); - let id = r["result"]["imported"][0].as_u64().unwrap(); - h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); - h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; - let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).expect("a file in Recently Deleted"); - opts.set_on_deleted(""); - h.settle(SETTLE); - let checked = |h: &Headless| matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Import { opts }) if opts.checked[trashed]); - assert!(!checked(&h), "Leave them: unchecked"); - let cell = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({"filter": format!("import:{trashed}")}), t); - let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == format!("import:{trashed}"))).cloned().expect("cell on screen"); - [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) - }; - // the checkbox: 16 pt, 6 pt in from the cell's top-left corner - let [x, y, ..] = cell(&mut h); - h.request("ui.click", json!({"x": x + 14.0, "y": y + 14.0}), t); - h.settle(SETTLE); - assert!(checked(&h), "a click on the checkbox checks the file"); - // the badges ("Will be restored" at the top right, "In Recently Deleted" under the - // checkbox) don't swallow the click either - let [x, y, w, _] = cell(&mut h); - h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 12.0}), t); - h.settle(SETTLE); - assert!(!checked(&h), "a click on the badge unchecks it again"); - h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 34.0}), t); - h.settle(SETTLE); - assert!(checked(&h)); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let t0 = std::time::Instant::now(); - while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - assert!(h.app.import.is_none(), "finished"); - let photos = &h.app.session.catalog; - assert!(photos.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); - assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2); - assert_eq!(photos.len(), 2, "restored, not imported a second time"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its - /// corner: the photo grid takes the new height, and the window then keeps its size (issue #337). - #[test] - fn import_review_dialog_resizes() { - let mut h = demo([1400.0, 1000.0]); - let t = Duration::from_secs(10); - let candidates = (0..60) - .map(|i| lightcraft_engine::import::ImportCandidate { - path: format!("/lc-test/img{i}.png"), - name: format!("img{i}.png"), - error: Some("not read in this test".into()), - ..Default::default() - }) - .collect(); - h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(candidates)) }); - let rect = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({}), t); - let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")).expect("dialog on screen"); - [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) - }; - for _ in 0..10 { - h.step(); - } - let r0 = rect(&mut h); - assert!(r0[3] > 600.0, "{r0:?}"); - // the Copy options add rows below the grid: the grid makes room, the window doesn't grow - for copy in [true, false] { - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - opts.copy = copy; - } - for _ in 0..10 { - h.step(); - } - let r = rect(&mut h); - assert!((r[3] - r0[3]).abs() < 0.5, "copy {copy}: {r0:?} → {r:?}"); - } - let (x, y) = (r0[0] + r0[2] - 3.0, r0[1] + r0[3] - 3.0); - h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 160.0, "toY": y + 120.0, "steps": 12}), t); - for _ in 0..10 { - h.step(); - } - let r1 = rect(&mut h); - assert!(r1[2] > r0[2] + 100.0 && r1[3] > r0[3] + 80.0, "dragging the corner resized it: {r0:?} → {r1:?}"); - for _ in 0..60 { - h.step(); - } - let r2 = rect(&mut h); - assert!((r2[2] - r1[2]).abs() < 0.5 && (r2[3] - r1[3]).abs() < 0.5, "the dialog kept its size: {r1:?} → {r2:?}"); - assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open"); - } - - /// Adding a file again (dropped on the window, no review) whose photo is in Recently Deleted - /// brings the photo back, selected in All Photos, and says so (issue #706) — it is not - /// skipped as a duplicate; the trash view's menus offer Restore and Delete Permanently, and a - /// re-add of a photo that is in the library selects it in All Photos. - #[test] - fn readding_a_deleted_photo_shows_it_in_recently_deleted() { - let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - let file = dir.join("flower.png"); - std::fs::write(&file, png).unwrap(); - let paths = vec![file.to_string_lossy().to_string()]; - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - // frames until the import has finished, so its (timed) toast is read before it expires - let finish_import = |h: &mut Headless| { - let t0 = std::time::Instant::now(); - while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - assert!(h.app.import.is_none(), "import finished"); - }; - crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); - h.settle(SETTLE); - let id = h.app.session.selection.active.expect("imported photo selected"); - let n0 = h.app.session.catalog.len(); - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); - assert!(h.app.session.catalog.photo(id).unwrap().deleted); - let photo_items = |app: &LightcraftApp| -> Vec { - let bar = crate::menubar::menu_bar(app); - let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1; - items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect() - }; - assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); - - crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); - finish_import(&mut h); - assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "re-adding the file restores the photo"); - assert_eq!(h.app.session.catalog.len(), n0, "no second record"); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); - assert_eq!(h.app.session.selection.ids, vec![id], "the restored photo is selected"); - let toast = h.app.ui.toast.clone().expect("toast").0; - assert!(toast.contains("Restored 1 photo from Recently Deleted"), "{toast}"); - assert!(!photo_items(&h.app).contains(&"photo.restore".to_string())); - // undo of the re-add puts it back in Recently Deleted; the trash view's menus offer Restore - h.request("engine.execute", json!({"command": "edit.undo"}), t); - assert!(h.app.session.catalog.photo(id).unwrap().deleted, "one undo step"); - h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), t); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.settle(SETTLE); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); - let items = photo_items(&h.app); - assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); - assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); - // a right-click on its filmstrip thumbnail opens the photo menu - h.request("ui.set", json!({"view": "detail"}), t); - h.settle(SETTLE); - let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell"); - assert!(!egui::Popup::is_any_open(&h.view.ctx)); - let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu"); - - // Empty Recently Deleted is offered while the trash is the source - assert!(photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string())); - let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored"); - crate::import::start_paths(&mut h.app, paths).unwrap(); - finish_import(&mut h); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); - assert_eq!(h.app.session.selection.ids, vec![id]); - let toast = h.app.ui.toast.clone().expect("toast").0; - assert!(toast.contains("All Photos"), "{toast}"); - assert!(!photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()), "Empty is for the trash view only"); - - // deleted permanently, the file imports afresh as a new photo - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); - h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t); - assert!(h.app.session.catalog.photo(id).is_none()); - crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap(); - h.settle(SETTLE); - let fresh = h.app.session.selection.active.expect("re-imported photo selected"); - assert_ne!(fresh, id); - assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Importing while an album is shown (issue #1446): the new photos aren't in it, so the view - /// switches to All Photos with the first of them active, instead of an active photo the grid - /// and the filmstrip don't show. - #[test] - fn an_import_from_an_album_view_shows_the_first_new_photo() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-show-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..3u8 { - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 40, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("IMG_{i:02}.png")), png).unwrap(); - } - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let album = h.app.session.catalog.albums().find(|a| !a.folder && !a.is_smart()).map(|a| a.id.0).expect("a demo album"); - let r = h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "album", "id": album}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let before: Vec = h.app.session.catalog.photos().map(|p| p.id).collect(); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - let active = h.app.session.selection.active.expect("a photo is active"); - assert!(!before.contains(&active), "the active photo is a new one: {active:?}"); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); - assert!(h.app.session.visible_cloned().contains(&active), "the grid shows the active photo"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across - /// the import's batches. - #[test] - fn import_copy_renames_into_destination() { - let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let (src, dest) = (base.join("card"), base.join("out")); - std::fs::create_dir_all(&src).unwrap(); - // more files than a batch holds (see `batch_size`) - for i in 0..20u8 { - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap(); - } - let dest_s = dest.to_string_lossy().to_string(); - let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.candidates.len(), 20); - for id in ["button:importCopy", "button:importDest"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - } - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - assert!(opts.copy); - assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder"); - opts.organize = "flat".into(); - opts.rename = "Trip-{seq:2}".into(); - } else { - panic!("no import dialog"); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - let mut names: Vec = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); - names.sort(); - let want: Vec = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect(); - assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size()); - let _ = std::fs::remove_dir_all(&base); - } - - /// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}` - /// files copies under `2026/20260114/`, the example line shows the full destination, and a - /// template that would climb out of the destination is refused. - #[test] - fn import_copy_into_a_custom_folder_template() { - let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let (src, dest) = (base.join("card"), base.join("out")); - std::fs::create_dir_all(&src).unwrap(); - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(src.join("IMG_01.png"), png).unwrap(); - // undated files are filed by the file's date (2026-01-14T05:58:48) - std::fs::File::options() - .write(true) - .open(src.join("IMG_01.png")) - .unwrap() - .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) - .unwrap(); - let dest_s = dest.to_string_lossy().to_string(); - let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; - let mut session = lightcraft_engine::Session::with_demo().with_fs(); - session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); - let mut app = LightcraftApp::new(session, services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - for id in ["button:importCopy", "button:importDest"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - } - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - opts.organize = "custom".into(); - opts.folder_template = "../{date:%Y}".into(); - } - h.step(); - // refused: the dialog stays open, nothing is imported - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_ne!(r["ok"], true, "{r}"); - assert!(h.app.import.is_none()); - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; - opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); - let opts = opts.clone(); - let sep = std::path::MAIN_SEPARATOR; - let example = crate::import::example_destination(&h.app, &opts).expect("example"); - assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy())); - h.step(); - let r = h.request("ui.widgets", json!({}), t); - assert!(r.to_string().contains("label:importExample"), "example shown"); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file()); - let _ = std::fs::remove_dir_all(&base); - } - - /// Import review, Move: an explicit choice with its explanation, the confirm button says - /// Move; the files land in the folder template, renamed, and leave the card. - #[test] - fn import_move_into_a_folder_template() { - let base = std::env::temp_dir().join(format!("lc-ui-import-move-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let (src, dest) = (base.join("card"), base.join("Photos")); - std::fs::create_dir_all(&src).unwrap(); - for i in 0..2u8 { - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); - std::fs::File::options() - .write(true) - .open(src.join(format!("IMG_0{i}.png"))) - .unwrap() - .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) - .unwrap(); - } - let dest_s = dest.to_string_lossy().to_string(); - let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; - let mut session = lightcraft_engine::Session::with_demo().with_fs(); - session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); - let mut app = LightcraftApp::new(session, services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - for id in ["button:importMove", "button:importDest"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - } - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import dialog") }; - assert!(opts.copy && opts.move_files, "Move chosen"); - opts.organize = "custom".into(); - opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); - opts.rename = "{date:%Y%m%d}_{seq:3}".into(); - let opts = opts.clone(); - let sep = std::path::MAIN_SEPARATOR; - let example = crate::import::example_destination(&h.app, &opts).expect("example"); - assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}20260114_001.png", dest.to_string_lossy())); - h.step(); - let r = h.request("ui.widgets", json!({}), t); - assert!(r.to_string().contains("label:importModeHelp"), "the Move explanation is shown"); - assert!(!r.to_string().contains("check:importDng"), "no Copy as DNG when moving"); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - let day = dest.join("2026").join("20260114"); - assert!(day.join("20260114_001.png").is_file() && day.join("20260114_002.png").is_file()); - assert_eq!(std::fs::read_dir(&src).unwrap().count(), 0, "moved off the card"); - let _ = std::fs::remove_dir_all(&base); - } - - /// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one - /// inserts it at the text cursor (not appended, not replacing the template). - #[test] - fn import_rename_tags_insert_at_the_cursor() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join("IMG_0007.png"), png).unwrap(); - let lib = dir.join("lib"); - let mut session = lightcraft_engine::Session::with_demo().with_fs(); - session.open_library(&lib, false).unwrap(); - let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); - let t = Duration::from_secs(10); - let r = - h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t); - assert_eq!(r["ok"], true, "{r}"); - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - opts.rename = "Trip-_x".into(); - } - // Paint the edited template before locating the Tags button in the resized dialog. - assert!(h.settle(SETTLE), "Copy dialog layout should settle before locating Tags"); - // the cursor sits after "Trip-" - let id = egui::Id::new("import-rename"); - let mut st = egui::text_edit::TextEditState::default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5)))); - st.store(&h.view.ctx, id); - let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t); - assert_eq!(r["ok"], true, "{r}"); - let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap(); - let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t); - assert_eq!(r["ok"], true, "{r}"); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; - assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor"); - // a second tag goes after the first (the cursor moved past it) - let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t); - assert_eq!(r["ok"], true, "{r}"); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; - assert_eq!(opts.rename, "Trip-{seq:3}{name}_x"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Help ▸ About: the About, Contributors and Models tabs switch and paint (credits are - /// compiled in). - #[test] - fn about_dialog_tabs_show_the_credits() { - // an empty library: the dialog needs no photos, and no decodes compete with other tests - let services = crate::Services { png: None, ..Default::default() }; - let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::new(), services), [1300.0, 820.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("ui.menu.invoke", json!({"id": "app.about"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About)); - // a new window sizes itself on its first frame: let it settle before clicking its tabs - h.step(); - h.step(); - for (i, (tab, _)) in crate::panels::dialogs::ABOUT_TABS.iter().enumerate().rev() { - let r = h.request("ui.clickWidget", json!({"id": format!("button:aboutTab-{tab}")}), t); - assert_eq!(r["ok"], true, "{tab}: {r}"); - h.step(); - let shown = h.view.ctx.data_mut(|d| d.get_temp::(egui::Id::new("about_tab"))); - assert_eq!(shown.map(usize::from), Some(i), "{tab}"); - } - h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t); - let img = h.snapshot(SETTLE); - assert_eq!(img.size, [1300, 820]); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About), "switching tabs keeps the dialog open"); - } - - /// Settings ▸ Display / `app.displayProfile`: previews are shown through the monitor profile - /// (the loupe rendered for the display, thumbnails converted from sRGB, the histogram still - /// sRGB); a file that can't be used is an error, or, as the setting, reported with previews - /// left sRGB. - #[test] - fn display_profile_converts_previews() { - use crate::render::Slot; - let dir = std::env::temp_dir().join(format!("lightcraft-display-ui-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p3 = dir.join("p3.icc"); - std::fs::write(&p3, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap(); - let bad = dir.join("bad.icc"); - std::fs::write(&bad, b"not a profile").unwrap(); - let (p3, bad) = (p3.to_string_lossy().to_string(), bad.to_string_lossy().to_string()); - - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - let thumb_slot = - |h: &Headless| h.app.renderer.textures.keys().copied().filter(|s| matches!(s, Slot::Thumb(_))).min_by_key(|s| format!("{s:?}")); - let ready = |h: &Headless| { - h.app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.pixels.is_some()) - && thumb_slot(h).is_some() - && !h.app.renderer.is_pending(Slot::Main) - }; - assert!(h.step_until(SETTLE, ready)); - h.settle(SETTLE); - let rgba = |c: &egui::ColorImage| lightcraft_raster::Rgba8 { - width: c.size[0], - height: c.size[1], - data: c.pixels.iter().map(|p| p.to_array()).collect(), - }; - let tex = |h: &Headless, s: Slot| rgba(h.app.renderer.textures.get(&s).and_then(|t| t.pixels.clone()).as_deref().unwrap()); - let thumb = thumb_slot(&h).unwrap(); - let (main0, thumb0) = (tex(&h, Slot::Main), tex(&h, thumb)); - let hist0 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); - - // the command loads it (and keeps it as the setting); textures are made again - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": p3}}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(r["result"]["kind"], "matrix"); - assert_eq!(h.app.ui.settings.display_profile, p3); - let d = h.app.renderer.display().cloned().unwrap(); - assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); - h.settle(SETTLE); - let (main1, thumb1) = (tex(&h, Slot::Main), tex(&h, thumb)); - let mut want = thumb0.clone(); - d.profile.from_srgb(&mut want).unwrap(); - let max_diff = |a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8| { - a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] as i32 - q[k] as i32).abs())).max().unwrap_or(0) - }; - assert_eq!((thumb1.width, thumb1.height), (thumb0.width, thumb0.height)); - assert!(max_diff(&thumb1, &want) <= 1, "thumbnails are converted from sRGB: {}", max_diff(&thumb1, &want)); - assert_ne!(thumb1, thumb0); - // the loupe: rendered for the display; colours sRGB holds look as they did - assert_eq!((main1.width, main1.height), (main0.width, main0.height)); - assert_ne!(main1, main0); - let mut main_want = main0.clone(); - d.profile.from_srgb(&mut main_want).unwrap(); - let mean = main1.data.iter().zip(&main_want.data).map(|(p, q)| (0..3).map(|k| (p[k] as f64 - q[k] as f64).abs()).sum::()).sum::() - / (3 * main1.data.len()) as f64; - assert!(mean < 1.5, "{mean}"); - let hist1 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); - assert_eq!(hist1.total, hist0.total, "the histogram stays sRGB"); - - // a bad file: an error, nothing changes - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": bad}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert_eq!(h.app.renderer.display_id(), Some(d.id())); - assert_eq!(h.app.ui.settings.display_profile, p3); - // …chosen as the setting (Settings ▸ Display), it is reported and previews go back to sRGB - h.app.ui.settings.display_profile = bad.clone(); - h.step(); - assert!(h.app.display_error.as_deref().is_some_and(|e| e.contains("not a valid ICC profile")), "{:?}", h.app.display_error); - assert_eq!(h.app.renderer.display_id(), None); - // the Display tab shows it - let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "display"}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - // none: sRGB again - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(r["result"]["path"].is_null()); - assert!(h.app.ui.settings.display_profile.is_empty() && h.app.display_error.is_none()); - assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); - h.settle(SETTLE); - assert_eq!(tex(&h, thumb), thumb0, "back to the sRGB thumbnail"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// With no profile chosen, previews follow the profile of the screen showing the window (the - /// host's `screen_profile`: macOS doesn't colour-manage the window, issue #787), read again so - /// a move to another screen is followed. A chosen profile wins; choosing none goes back to the - /// screen's at once; a screen without a profile is sRGB. - #[test] - fn previews_follow_the_screen_profile_while_none_is_chosen() { - use lightcraft_codecs::{NamedSpace, icc::write_named}; - use std::sync::{Arc, Mutex}; - let id = |space| lightcraft_engine::display::Display::from_bytes("", &write_named(space)).unwrap().id(); - let dir = std::env::temp_dir().join(format!("lightcraft-screen-profile-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let chosen = dir.join("prophoto.icc"); - std::fs::write(&chosen, write_named(NamedSpace::ProPhoto)).unwrap(); - let chosen = chosen.to_string_lossy().to_string(); - - let screen = Arc::new(Mutex::new(Some(write_named(NamedSpace::DisplayP3)))); - let mut h = demo([1000.0, 700.0]); - let read = screen.clone(); - h.app.services.screen_profile = Some(Box::new(move || read.lock().unwrap().clone())); - let t = Duration::from_secs(10); - h.step(); - assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3)), "the screen's profile"); - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {}}), t); - assert_eq!((r["result"]["screen"].clone(), r["result"]["path"].clone()), (json!(true), Value::Null), "{r}"); - assert!(h.app.ui.settings.display_profile.is_empty(), "nothing is saved as the setting"); - - // the window moves to another screen - *screen.lock().unwrap() = Some(write_named(NamedSpace::AdobeRgb)); - assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id() == Some(id(NamedSpace::AdobeRgb)))); - - // a chosen profile wins over the screen's - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": chosen}}), t); - assert_eq!(r["ok"], true, "{r}"); - *screen.lock().unwrap() = Some(write_named(NamedSpace::DisplayP3)); - for _ in 0..90 { - h.step(); - } - assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::ProPhoto))); - - // none chosen again: the screen's profile, at once - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); - assert_eq!(r["result"]["screen"], true, "{r}"); - assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3))); - - // a screen without a profile: sRGB - *screen.lock().unwrap() = None; - assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id().is_none())); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through - /// the engine; the delete confirmation guards ⌫. - #[test] - fn settings_dialog_by_keyboard_and_clicks() { - let mut h = demo([1300.0, 820.0]); - let t = Duration::from_secs(10); - let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() })); - for tab in ["import", "performance", "display", "interface", "general"] { - let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); - assert_eq!(r["ok"], true, "{tab}: {r}"); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); - } - // General: confirm before delete (on by default), startup view - assert!(h.app.ui.settings.confirm_delete); - h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); - assert!(!h.app.ui.settings.confirm_delete); - h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); - assert!(h.app.ui.settings.confirm_delete); - h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); - assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); - // Interface: filmstrip names off, grid badges always - h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); - // Appearance cards (one row per theme, with its favorite star) sit above these controls; - // scroll the compact settings viewport past them. - h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); - h.request("ui.scroll", json!({"dy": -700.0}), t); - for _ in 0..4 { - h.step(); - } - h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); - assert!(!h.app.ui.settings.film_names); - h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); - assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); - h.request("ui.scroll", json!({"dy": 700.0}), t); - for _ in 0..4 { - h.step(); - } - // Performance: the thumbnail cache size goes to the library preferences - h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); - h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); - assert_eq!(h.app.session.cache_mb, 512); - h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t); - assert_eq!(h.app.ui.settings.preview_limit, 3840); - h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); - assert_eq!(h.app.ui.settings.memory_mb, 1024); - h.step(); - assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget"); - h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); - h.step(); - assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic"); - // Import: per-camera defaults - h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); - h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); - assert!(h.app.session.import_defaults.per_camera); - let img = h.snapshot(SETTLE); - assert_eq!(img.size, [1300, 820]); - // Escape closes; ⌫ now asks first - h.request("ui.key", json!({"key": "escape"}), t); - assert_eq!(h.app.ui.dialog, None); - let active = h.app.session.active().unwrap(); - h.request("ui.key", json!({"key": "delete"}), t); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); - assert!(!h.app.session.catalog.photo(active).unwrap().deleted); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.session.catalog.photo(active).unwrap().deleted); - // app settings survive a save/load round trip of the UI state (ui.json) - let saved = serde_json::to_string(&h.app.ui).unwrap(); - let back = serde_json::from_str::(&saved).unwrap().sanitized(); - assert_eq!(back.settings, h.app.ui.settings); - assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load"); - h.settle(SETTLE); - } - - /// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the - /// overlay in Detail; ⇧⌘F asks the host for window full screen. - #[test] - fn full_screen_preview_and_info_overlay_by_keyboard() { - use crate::state::InfoOverlay; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t); - h.request("ui.key", json!({"key": "f"}), t); - assert!(h.app.ui.fullscreen); - h.request("ui.key", json!({"key": "right"}), t); - assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1])); - let right = h.app.ui.right; - h.request("ui.key", json!({"key": "i"}), t); - assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic); - assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen"); - let img = h.snapshot(SETTLE); - // no chrome: the frame's corners are black - for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] { - let c = img.pixels[y * 1000 + x]; - assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}"); - } - assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay")); - h.request("ui.key", json!({"key": "escape"}), t); - assert!(!h.app.ui.fullscreen); - assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered"); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("ui.key", json!({"key": "i", "cmd": true}), t); - assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure); - h.request("ui.key", json!({"key": "i", "cmd": true}), t); - assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); - h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); - assert!(h.app.window_is_fullscreen); - h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); - assert!(!h.app.window_is_fullscreen); - h.settle(SETTLE); - } - - /// #911 (toast labels only): cycling the overlay announces each mode in the UI language. - #[test] - fn info_overlay_toasts_are_painted_in_the_users_language() { - use crate::i18n::Locale; - - struct RestoreLanguage(Locale); - impl Drop for RestoreLanguage { - fn drop(&mut self) { - crate::i18n::set_language(self.0); - } - } - let _restore = RestoreLanguage(crate::i18n::language()); - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); - for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { - assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); - assert_eq!(h.app.ui.language, locale); - assert_eq!(crate::i18n::language(), locale); - for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { - let expected = locale.tr(key); - assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); - assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); - assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); - h.step(); - let painted = h.painted_text(); - assert!(painted.iter().any(|text| text == expected), "{painted:?}"); - } - } - } - - /// ⌘J opens View ▸ View Options; its menus choose Info 1's and Info 2's lines, Use Defaults puts - /// them back, and `view.viewOptions` sets them for an agent (refusing a line it doesn't know). - #[test] - fn view_options_choose_the_info_overlay_lines() { - use crate::state::{LoupeInfo, LoupeInfoField as F}; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ViewOptions)); - h.step(); - assert_eq!(h.request("ui.clickWidget", json!({"id": "combo:viewOptionsInfo1Line2"}), t)["ok"], true); - h.step(); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsInfo1Line2-lens"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.ui.settings.loupe_info.info1, [F::FileName, F::Lens, F::Dimensions]); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsDefaults1"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.ui.settings.loupe_info.info1, LoupeInfo::INFO1); - - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["camera", "none", "fileName"]}}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName]); - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["rating", "none", "none"]}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing"); - } - - /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the - /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. - #[test] - fn click_zoom_ratio_animates() { - use crate::render::Slot; - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default"); - let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t); - assert_eq!(r["result"]["ratio"], 3, "{r}"); - let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t); - assert_ne!(r["ok"], true, "{r}"); - let fit = h.app.image_rect.unwrap(); - h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); - assert!(h.app.ui.zoom_anim, "animation started"); - // every animation frame asks for the same (final-size) render - let mut keys = std::collections::HashSet::new(); - for _ in 0..40 { - h.step(); - keys.extend(h.app.renderer.wanted(Slot::Main)); - if !h.app.ui.zoom_anim { - break; - } - } - assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}"); - h.settle(SETTLE); - assert!(!h.app.ui.zoom_anim, "animation finished"); - assert!(h.app.image_rect.unwrap().width() > fit.width()); - h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - // Z zooms to the same ratio as a click - h.request("ui.key", json!({"key": "z"}), t); - assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); - h.request("ui.key", json!({"key": "z"}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - h.settle(SETTLE); - } - - /// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the - /// loupe render is, not a preview-level render the GPU shrinks. - #[test] - fn a_hover_preview_is_fitted_to_the_drawn_size() { - use crate::render::Slot; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.profiles"}), t); - h.settle(SETTLE); - h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); - let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); - let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); - let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); - assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); - assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); - } - - /// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of - /// rising to the preview level. - #[test] - fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { - use crate::render::Slot; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - h.settle(SETTLE); - assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); - h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); - let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; - assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); - } - - #[test] - fn trackpad_navigation_keeps_the_image_cursor_between_events() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), t); - h.request("engine.execute", json!({"command": "view.zoom100"}), t); - h.settle(SETTLE); - let anchor = h.app.canvas_rect.unwrap().center(); - h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); - for (tool, expected) in [ - ("", egui::CursorIcon::Grab), - ("wbPicker", egui::CursorIcon::Crosshair), - ("colorRange", egui::CursorIcon::Crosshair), - ("pointColor", egui::CursorIcon::Crosshair), - ("tat:curve", egui::CursorIcon::ResizeVertical), - ] { - h.app.ui.tool = tool.into(); - for event in [ - None, - Some(egui::Event::Zoom(1.01)), - None, - Some(egui::Event::Zoom(1.01)), - Some(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(10.0, -10.0), - phase: egui::TouchPhase::Move, - modifiers: egui::Modifiers::NONE, - }), - None, - ] { - let raw = HeadlessView::raw_input(h.size, h.pixels_per_point, h.time, event.into_iter().collect()); - let mut cursor = egui::CursorIcon::Default; - h.view.run(raw, |ui| { - h.app.logic(ui.ctx()); - h.app.ui(ui); - cursor = ui.ctx().output(|output| output.cursor_icon); - }); - h.time += FRAME_DT; - assert_eq!(cursor, expected, "cursor changed between navigation events with tool {tool:?}"); - } - } - } - - #[test] - fn trackpad_pinch_anchors_fractional_zoom_and_scroll_pans() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - h.pixels_per_point = 2.0; - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); - h.request("engine.execute", json!({"command": "view.zoom100"}), t); - h.settle(SETTLE); - let before = h.app.image_rect.unwrap(); - let anchor = h.app.canvas_rect.unwrap().center() + egui::vec2(30.0, -20.0); - h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); - let point = (anchor - before.min) / before.size(); - h.request("ui.zoom", json!({"factor": 1.001}), t); - let after = h.app.image_rect.unwrap(); - assert!((after.width() / before.width() - 1.001).abs() < 0.00001); - assert!(((anchor - after.min) / after.size() - point).length() < 0.00001, "point under pointer moved"); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if (p - 100.1).abs() < 0.001), "{:?}", h.app.ui.zoom); - assert!(!h.app.ui.zoom_anim, "pinch follows the fingers immediately"); - - h.request("ui.scroll", json!({"dx": 40.0, "dy": -30.0}), t); - for _ in 0..40 { - h.step(); - } - let panned = h.app.image_rect.unwrap(); - assert!(panned.left() > after.left() && panned.top() < after.top(), "two-finger pan must move both axes"); - h.request("ui.scroll", json!({"dx": 100000.0, "dy": -100000.0}), t); - for _ in 0..40 { - h.step(); - } - let edge = h.app.image_rect.unwrap(); - let area = h.app.canvas_rect.unwrap().shrink(24.0); - assert!((edge.left() - area.left()).abs() < 0.01 && (edge.bottom() - area.bottom()).abs() < 0.01, "{edge:?} vs {area:?}"); - - h.request("ui.zoom", json!({"factor": 1e20}), t); - assert_eq!(h.app.ui.zoom, Zoom::Percent(800.0)); - h.request("ui.zoom", json!({"factor": 0.000001}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - assert_eq!(h.app.ui.pan, (0.5, 0.5)); - // Panel / toolbar gestures must not move the image. - h.request("ui.move", json!({"x": 10, "y": 10}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - h.request("ui.scroll", json!({"dx": 100, "dy": 100}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - assert_eq!(h.app.ui.pan, (0.5, 0.5)); - } - - #[test] - fn mouse_wheel_zooms_the_loupe_with_and_without_ctrl() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); - h.settle(SETTLE); - for (unit, y, modifiers) in [ - (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE), - (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL), - (egui::MouseWheelUnit::Point, 50.0, egui::Modifiers::CTRL), - ] { - h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); - h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); - h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers }); - for _ in 0..10 { - h.step(); - } - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "wheel {unit:?} {modifiers:?} must zoom in, got {:?}", h.app.ui.zoom); - } - } - - #[test] - fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "leftPanel": false, "filmstrip": false}), t); - h.settle(SETTLE); - let active = h.app.session.active().unwrap(); - let settings = h.app.session.catalog.photo(active).unwrap().develop.clone(); - for right in ["crop", "masking", "remove", "redEye", "none"] { - h.request("ui.set", json!({"right": right, "zoom": "fit", "pan": [0.5, 0.5]}), t); - h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch in {right}"); - assert_eq!(h.app.session.catalog.photo(active).unwrap().develop, settings, "navigation must not edit the photo"); - } - h.request("ui.set", json!({"fullscreen": true, "zoom": "fit"}), t); - h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "full-screen pinch"); - - h.request("ui.set", json!({"fullscreen": false, "zoom": "fit", "beforeAfter": "sideBySide"}), t); - let canvas = h.app.canvas_rect.unwrap(); - h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch over the Before pane"); - - h.request("ui.set", json!({"beforeAfter": "off", "zoom": "fit"}), t); - h.request("engine.execute", json!({"command": "view.compare"}), t); - // The left Compare pane must navigate too; zoom/pan are shared with the right pane. - let canvas = h.app.canvas_rect.unwrap(); - h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Compare pinch"); - let pan = h.app.ui.pan; - h.request("ui.scroll", json!({"dx": -50, "dy": -50}), t); - assert_ne!(h.app.ui.pan, pan, "Compare pan"); - - h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); - h.request("engine.execute", json!({"command": "view.reference"}), t); - h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Reference pinch"); - } - - #[test] - fn navigation_rejects_invalid_input_and_preserves_old_zoom_state() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - assert_eq!(serde_json::from_value::(json!({"percent": 100})).unwrap(), Zoom::Percent(100.0)); - for factor in [json!(0), json!(-1), json!(1e308), json!("big"), Value::Null] { - let r = h.request("ui.zoom", json!({"factor": factor}), t); - assert_eq!(r["ok"], false, "{r}"); - } - for params in [ - json!({"zoom": {"percent": -1}}), - json!({"zoom": {"percent": 1e308}}), - json!({"zoom": "oops"}), - json!({"zoom": {"percent": 123.4}, "pan": [-1, 0.5]}), - json!({"pan": [0.5, 1e308]}), - json!({"pan": [0.5]}), - ] { - assert!(h.app.run("view.navigate", params).is_err()); - assert_eq!(h.app.ui.zoom, Zoom::Fit, "failed request changed zoom"); - assert_eq!(h.app.ui.pan, (0.5, 0.5), "failed request changed pan"); - } - h.app.run("view.navigate", json!({"zoom": {"percent": 123.4}, "pan": [0.4, 0.6]})).unwrap(); - assert_eq!(h.app.ui.zoom, Zoom::Percent(123.4)); - assert_eq!(h.app.ui.pan, (0.4, 0.6)); - h.app.run("view.zoomIn", json!({})).unwrap(); - assert_eq!(h.app.ui.zoom, Zoom::Percent(200.0)); - h.app.run("view.zoomOut", json!({})).unwrap(); - assert_eq!(h.app.ui.zoom, Zoom::Percent(100.0)); - - let area = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(1000.0, 700.0)); - for pan in [(0.0, 0.0), (1.0, 1.0)] { - let image = crate::panels::detail::fit_rect(area, 10.0, Zoom::Percent(100.0), [4000, 400], 1.0, pan); - assert_eq!(image.center().y, area.center().y, "smaller axis must stay centred"); - assert!(image.left() <= area.left() && image.right() >= area.right(), "pan exposed space outside the photo"); - } - } - - /// The Navigator appears when zoomed in; clicking it pans to that point. - #[test] - fn navigator_pans_the_zoomed_loupe() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit"); - h.request("engine.execute", json!({"command": "view.zoom100"}), t); - h.settle(SETTLE); - assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed"); - h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t); - let (u, v) = h.app.ui.pan; - assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan); - assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0), "the click didn't reach the loupe (which would zoom out)"); - h.settle(SETTLE); - let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap(); - let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7); - h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t); - let (u, v) = h.app.ui.pan; - assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan); - h.request("engine.execute", json!({"command": "view.navigator"}), t); - h.settle(SETTLE); - assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); - } - - /// While an edit renders, the loupe shows the last render: it says "Rendering…" once that has - /// lasted a moment (not at once after a spell away from the loupe, when no frames were drawn), - /// and stops when the render lands. - #[test] - fn the_loupe_says_rendering_while_an_edit_renders() { - use crate::render::Slot; - use std::{cell::Cell, rc::Rc}; - let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert!(!pill(&h), "a finished render"); - let open = Rc::new(Cell::new(false)); - h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); - // half a second in the grid: the loupe draws no frames - h.request("ui.set", json!({"view": "photoGrid"}), t); - for _ in 0..30 { - h.step(); - } - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - for _ in 0..12 { - h.step(); - } - assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); - assert!(!pill(&h), "not at once"); - for _ in 0..30 { - h.step(); - } - assert!(pill(&h), "the last render is on screen and the edit's is on its way"); - open.set(true); - assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); - assert!(!h.app.renderer.is_pending(Slot::Main)); - } - - /// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left - /// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the - /// error on hover, and keeps saying so while a library change retries it. - #[test] - fn the_loupe_says_so_when_the_render_behind_a_stand_in_fails() { - use crate::render::Slot; - use std::{cell::Cell, rc::Rc}; - let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert!(!has(&h, "loupe:renderFailed")); - let first = h.app.session.active().expect("a photo is open").0; - // every loupe render fails, as an undecodable raw's would; the rest render - let open = Rc::new(Cell::new(true)); - h.app.renderer.set_offload(Box::new(super::RenderGate::failing(open.clone(), |s| s == Slot::Main))); - let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo").0; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other], "active": other}}), t); - assert!(h.step_until(SETTLE, |h| has(h, "loupe:renderFailed")), "the failure is said"); - let shown = h.app.loupe_shown.map(|l| l.1); - assert!(!matches!(shown, Some("render" | "none")), "over a stand-in: {shown:?}"); - assert!(!has(&h, "loupe:rendering")); - let r = h.request("ui.inspect", json!({}), t); - assert_eq!(r["result"]["loupe"]["failed"], "no decoder for this raw", "{}", r["result"]["loupe"]); - // a library change (here a rating) retries it: the failure stays said, no "Rendering…" - open.set(false); - h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 2}}), t); - for _ in 0..45 { - h.step(); - } - assert!(h.app.renderer.is_pending(Slot::Main), "the retry is held"); - assert!(has(&h, "loupe:renderFailed") && !has(&h, "loupe:rendering"), "no blink while it retries"); - // back on a photo that rendered: nothing to say - open.set(true); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first], "active": first}}), t); - h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); - assert!(h.step_until(SETTLE, |h| h.app.loupe_shown.is_some_and(|l| l.1 == "render") && !has(h, "loupe:renderFailed"))); - } - - #[test] - fn screenshot_request_is_answered_without_a_window() { - let mut h = demo([800.0, 500.0]); - let r = h.request("ui.screenshot", json!({}), SETTLE); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(r["result"]["width"], 800); - assert_eq!(r["result"]["height"], 500); - let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5)); - assert_eq!(r["ok"], true); - let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE); - assert_eq!(r["result"]["width"], 640, "{r}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs new file mode 100644 index 000000000..f38ed636a --- /dev/null +++ b/crates/ui-egui/src/headless/tests.rs @@ -0,0 +1,4932 @@ +#[test] +fn detail_offers_ai_denoise_for_raw_photos_and_leads_to_its_settings() { + use crate::state::Dialog; + use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-denoise-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); + // a raw photo (its file need not exist: nothing here reads it) + let id = PhotoId(h.app.session.catalog.photos().map(|p| p.id.0).max().unwrap_or(0) + 1); + let mut raw = Photo::new( + id, + Source::File { path: dir.join("IMG_1.dng").to_string_lossy().into_owned() }, + "IMG_1.dng", + "DNG", + 6000, + 4000, + "2026-10-01T00:00:00", + ); + raw.kind = MediaKind::Raw; + h.app.session.commit("setup", Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let widgets = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.app.ui.open_sections = vec!["detail".to_string()]; + h.settle(SETTLE); + h.step(); + h.step(); + let w = widgets(&mut h); + assert!(w.contains("\"denoise:setup\""), "a raw photo with no model is offered the setup: {w}"); + // One click lands in Settings, with local installation available even without a download offer. + let r = h.request("ui.clickWidget", json!({"id": "denoise:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); + h.settle(SETTLE); + let listed = h.app.session.execute("denoise.models.list", &json!({})).unwrap(); + assert_eq!(listed["models"].as_array().unwrap().len(), lightcraft_denoise::known::all().len(), "{listed}"); + assert!(widgets(&mut h).contains("\"denoise:installFile\"")); + h.app.ui.dialog = None; + // a photo that is not raw: no offer + h.request( + "engine.execute", + json!({"command": "library.select", "params": {"ids": [h.app.session.catalog.photos().find(|p| p.id != id).unwrap().id.0]}}), + t, + ); + h.settle(SETTLE); + h.step(); + h.step(); + assert!(!widgets(&mut h).contains("\"denoise:setup\""), "nothing is offered for a photo denoise does not apply to"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Install from file… with a model the engine rejects says why in the AI Denoise section (#1381), instead of +/// leaving the button looking dead. +#[test] +fn a_rejected_model_file_says_why_in_settings() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-denoise-rejected-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); + // without the runtime the button is disabled (the workspace build turns it on through the desktop app) + if h.app.session.execute("denoise.models.list", &json!({})).unwrap()["runtime"] != true { + let _ = std::fs::remove_dir_all(&dir); + return; + } + // another app's model folder: the .onnx with that app's model.json, and no denoise-model.json + let onnx = dir.join("rawdenoise.onnx"); + std::fs::write(&onnx, b"not a model").unwrap(); + std::fs::write(dir.join("model.json"), br#"{"name": "rawdenoise", "scale": 2}"#).unwrap(); + let picked = onnx.to_string_lossy().into_owned(); + h.app.services.pick_denoise_model = Some(Box::new(move || vec![picked.clone()])); + h.app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() }); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "denoise:installFile"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); + let painted = h.painted_text(); + assert!( + painted + .iter() + .any(|s| s.starts_with("No denoise-model.json beside this .onnx file. The model.json there") && s.ends_with("(see docs/denoise.md)")), + "{painted:?}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +use super::*; +use std::sync::Arc; + +/// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a +/// cut, copy or paste request (from a context menu) comes back as that event on the next frame. +#[test] +fn the_headless_host_has_a_clipboard() { + let mut view = HeadlessView::new(); + let raw = || HeadlessView::raw_input(egui::vec2(200.0, 100.0), 1.0, 0.0, vec![]); + view.run(raw(), |ui| ui.ctx().copy_text("Lisbon".into())); + assert_eq!(view.clipboard, "Lisbon"); + for (command, event) in [ + (ViewportCommand::RequestPaste, egui::Event::Paste("Lisbon".into())), + (ViewportCommand::RequestCopy, egui::Event::Copy), + (ViewportCommand::RequestCut, egui::Event::Cut), + ] { + view.run(raw(), |ui| ui.ctx().send_viewport_cmd(command.clone())); + let mut seen = vec![]; + view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); + assert!(seen.contains(&event), "{command:?}: {seen:?}"); + } + // an empty clipboard pastes nothing, as a desktop host does + view.clipboard.clear(); + view.run(raw(), |ui| ui.ctx().send_viewport_cmd(ViewportCommand::RequestPaste)); + let mut seen = vec![]; + view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); + assert!(!seen.iter().any(|e| matches!(e, egui::Event::Paste(_))), "{seen:?}"); +} + +#[test] +fn screenshot_dimensions_are_checked_before_layout() { + assert_eq!(viewport_pixels([1600.0, 1000.0], 2.0).unwrap(), [3200, 2000]); + assert_eq!(viewport_pixels([345.0, 200.0], 0.9).unwrap(), [311, 180]); + // The largest accepted surface is exactly the 128 MiB screenshot budget (32 Mi pixels). + assert_eq!(viewport_pixels([16384.0, 2048.0], 1.0).unwrap(), [16384, 2048]); + assert_eq!(viewport_pixels([5792.0, 5792.0], 1.0).unwrap(), [5792, 5792]); + for (size, scale) in [ + ([f32::NAN, 10.0], 1.0), + ([10.0, f32::INFINITY], 1.0), + ([10.0, 10.0], f32::INFINITY), + ([10.0, 10.0], f32::NAN), + ([10.0, 10.0], 0.0), + ([0.0, 10.0], 1.0), + ([10.0, 10.0], 0.01), + ([16384.0, 2049.0], 1.0), + ([8000.0, 8000.0], 1.0), + ([5793.0, 5793.0], 1.0), + ([9000.0, 9000.0], 1.0), + ([9000.0, 100.0], 2.0), + ] { + assert!(viewport_pixels(size, scale).is_err(), "{size:?} at {scale}"); + } + let raw = HeadlessView::raw_input(egui::vec2(f32::INFINITY, 10.0), 1.0, 0.0, vec![]); + assert_eq!(raw.screen_rect.unwrap().size(), egui::vec2(1600.0, 1000.0)); +} + +#[test] +fn ui_zoom_preserves_host_pixels_and_scales_layout() { + for (size, scale, expected) in + [(egui::vec2(400.0, 240.0), 1.0, [400, 240]), (egui::vec2(345.0, 200.0), 0.9, [311, 180]), (egui::vec2(400.0, 240.0), 2.0, [800, 480])] + { + let mut view = HeadlessView::new(); + for frame in 0..9 { + // the interface scale changes between frames (Cmd+Plus / Cmd+Minus are the + // photo's zoom, issue #566: egui's own keyboard zoom is off) + match frame { + 1 => view.ctx.set_zoom_factor(1.1), + 5 => view.ctx.set_zoom_factor(1.0), + _ => {} + } + let raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| { + ui.label("UI zoom changes layout, keeping the host viewport size"); + }); + assert_eq!(view.size_px(), expected, "frame {frame} at scale {scale}"); + assert_eq!(view.paint(&HashMap::new()).size, expected); + if frame == 4 { + assert!(view.ctx.zoom_factor() > 1.0); + assert!(view.ctx.content_rect().width() < size.x); + } + if frame == 8 { + assert!(view.ctx.zoom_factor() < 1.01); + } + } + } +} + +#[test] +fn shadow_screenshot_keeps_the_zoomed_hosts_size_and_scale() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], 2.0); + let t = Duration::from_secs(5); + h.view.ctx.set_zoom_factor(1.1); + assert_eq!(h.request("ui.inspect", json!({}), t)["ok"], true); + assert!(h.view.ctx.zoom_factor() > 1.0); + let main = h.view.ctx.clone(); + let screenshot = h.app.headless_screenshot(&main, true).unwrap(); + assert_eq!(screenshot.size, [960, 640]); + let shadow = h.app.shadow.as_ref().unwrap(); + assert!((shadow.ctx.pixels_per_point() - main.pixels_per_point()).abs() < 0.001); + assert!((shadow.ctx.content_rect().width() - main.content_rect().width()).abs() < 0.1); +} + +#[test] +fn control_resize_uses_effective_points_after_ui_zoom() { + for scale in [0.9, 1.0, 2.0] { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], scale); + let t = Duration::from_secs(5); + assert_eq!(h.request("ui.zoomFactor", json!({"factor": 1.1}), t)["ok"], true); + h.step(); + assert!(h.view.ctx.zoom_factor() > 1.0); + let native_size = resized_viewport([320.0, 240.0], scale, h.view.ctx.zoom_factor()).unwrap(); + let expected = native_viewport_pixels([native_size.x, native_size.y], scale).unwrap(); + assert_eq!(h.request("ui.resize", json!({"width": 320, "height": 240}), t)["ok"], true); + assert_eq!(h.view.size_px(), expected); + assert!((h.view.ctx.content_rect().width() - 320.0).abs() < 0.1); + let main = h.view.ctx.clone(); + assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, expected); + } +} + +#[test] +fn control_resize_keeps_a_minimum_width_after_zooming_out() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], 1.0); + h.view.ctx.set_zoom_factor(0.5); + let t = Duration::from_secs(5); + assert_eq!(h.request("ui.resize", json!({"width": 1, "height": 120}), t)["ok"], true); + assert_eq!(h.size, egui::vec2(0.5, 60.0)); + assert_eq!(h.view.size_px(), [1, 60]); + assert!((h.view.ctx.content_rect().width() - 1.0).abs() < 0.01); + let main = h.view.ctx.clone(); + assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [1, 60]); +} + +#[test] +fn control_resize_rounds_in_native_points_after_a_fractional_zoom_out() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], 0.9); + let t = Duration::from_secs(5); + h.view.ctx.set_zoom_factor(0.9); + h.step(); + assert!((h.view.ctx.zoom_factor() - 0.9).abs() < 0.001); + assert_eq!(h.request("ui.resize", json!({"width": 150, "height": 150}), t)["ok"], true); + assert_eq!(h.size, egui::vec2(135.0, 135.0)); + assert_eq!(h.view.size_px(), [122, 122]); + assert!((h.view.ctx.content_rect().width() - 150.0).abs() < 0.01); + let main = h.view.ctx.clone(); + assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [122, 122]); +} + +#[test] +fn invalid_control_resize_preserves_the_headless_viewport() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + let mut h = Headless::new(app, [480.0, 320.0], 2.0); + h.app.ui.settings.gpu = false; + h.step(); + let size = h.size; + let rejected = h.request("ui.resize", json!({"width": 9000, "height": 8000}), Duration::from_secs(5)); + assert_eq!(rejected["ok"], false, "{rejected}"); + assert_eq!(h.size, size); + for bad in [json!({"width": "320", "height": 240}), json!({"width": 320, "height": null}), json!({"width": true})] { + let rejected = h.request("ui.resize", bad, Duration::from_secs(5)); + assert_eq!(rejected["ok"], false, "{rejected}"); + assert_eq!(h.size, size); + } + let accepted = h.request("ui.resize", json!({"width": 320, "height": 240}), Duration::from_secs(5)); + assert_eq!(accepted["ok"], true, "{accepted}"); + assert_eq!(h.size, egui::vec2(320.0, 240.0)); + assert_eq!(h.view.size_px(), [640, 480]); +} + +/// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out +/// settle would show a half-rendered UI. +const SETTLE: Duration = Duration::from_secs(120); + +fn demo(size: [f32; 2]) -> Headless { + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + Headless::new(app, size, 1.0) +} + +/// Agents cut, copy and paste in a text field as the system does for ⌘X / ⌘C / ⌘V +/// (`ui.clipboard`), and `ui.inspect` → `copied` says what the UI last copied. +#[test] +fn agents_cut_copy_and_paste_in_text_fields() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + r + }; + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:search"})); + ask(&mut h, "ui.text", json!({"text": "wedding"})); + ask(&mut h, "ui.key", json!({"key": "a", "cmd": true})); + ask(&mut h, "ui.clipboard", json!({"action": "copy"})); + assert_eq!(ask(&mut h, "ui.inspect", json!({}))["result"]["copied"], "wedding"); + ask(&mut h, "ui.clipboard", json!({"action": "cut"})); + assert_eq!(h.app.ui.search, ""); + ask(&mut h, "ui.clipboard", json!({"action": "paste", "text": "travel"})); + assert_eq!(h.app.ui.search, "travel"); + let r = h.request("ui.clipboard", json!({"action": "shred"}), t); + assert_eq!(r["ok"], false, "an unknown action is refused: {r}"); +} + +/// `ui.clipboard` acts in a text field: with none focused it is refused, rather than doing +/// what ⌘V does there on one platform (paste edit settings) and nothing on another. +#[test] +fn the_clipboard_method_needs_a_text_field() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let undo = h.app.session.undo.len(); + for action in ["copy", "paste"] { + let r = h.request("ui.clipboard", json!({"action": action, "text": "travel"}), t); + assert_eq!(r["ok"], false, "{action}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("text field")), "{r}"); + } + h.settle(SETTLE); + assert_eq!(h.app.session.undo.len(), undo, "nothing was pasted"); +} + +/// `ui.clickWidget` right-clicks with `button: "right"`, as `ui.click` does (MCP `click` +/// passes it on with a widget): a filmstrip photo's context menu opens. +#[test] +fn click_widget_right_clicks() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| w.starts_with("film:")).map(|(w, _)| w.clone()).expect("a filmstrip photo"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.clickWidget", json!({"id": cell, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); +} + +/// #1877: a right-click while another photo's context menu is open closes that menu and +/// opens this photo's, which stays open (it flashed and closed again). +#[test] +fn right_clicking_elsewhere_moves_the_context_menu() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let thumbs: Vec = h.app.widgets.iter().filter(|(w, _)| w.starts_with("thumb:")).map(|(w, _)| w.clone()).collect(); + let (Some(a), Some(b)) = (thumbs.first().cloned(), thumbs.get(1).cloned()) else { panic!("two grid photos: {thumbs:?}") }; + let popup = |w: &str| { + let id: u64 = w.trim_start_matches("thumb:").parse().expect("a photo id"); + egui::Id::unique(("cell", id)).with("popup") + }; + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.clickWidget", json!({"id": a, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "the first photo's menu is open"); + // A real click: the press and the release reach the UI on different frames, with the + // pointer resting on the photo in between (`ui.clickWidget` sends both on one frame). + let rect = h.app.widgets.iter().find(|(w, _)| *w == b).map(|(_, r)| *r).expect("the second photo on screen"); + let at = egui::pos2(rect.left() + rect.width() * 0.9, rect.center().y); + let button = egui::PointerButton::Secondary; + let modifiers = egui::Modifiers::NONE; + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); + // one frame at a time: the menu flashed for a frame or two before it closed + for frame in 0..6 { + h.step(); + assert!(!egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "frame {frame}: the first photo's menu closed"); + if frame > 0 { + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the second photo's menu is open"); + } + } + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the second photo's menu stays open"); + // The same photo again, elsewhere on it (the menu that is open belongs to this very + // widget): the menu moves to the pointer and stays. + let before = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); + let at = egui::pos2(rect.left() + rect.width() * 0.2, rect.center().y); + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); + for frame in 0..6 { + h.step(); + if frame > 0 { + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the menu is open after a right-click on the same photo"); + } + } + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the menu stays open"); + let after = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); + assert!((after.left() - at.x).abs() < 4.0, "the menu moved to the pointer: {after:?} vs {at:?} (was {before:?})"); +} + +/// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo. +#[test] +fn up_and_down_move_through_the_grid_rows() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "grid"}), t); + h.settle(SETTLE); + let first = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first.0]}}), t); + h.settle(SETTLE); + let rect = |h: &Headless| { + let a = h.app.session.selection.active.unwrap(); + h.app.widgets.iter().find(|(w, _)| *w == format!("thumb:{}", a.0)).map(|(_, r)| *r).expect("grid cell") + }; + let top = rect(&h); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + let below = rect(&h); + let pos = { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + } + .unwrap(); + assert!(below.top() > top.bottom(), "the next row, at {pos}: {top:?} -> {below:?}"); + assert!(below.left() < top.right() && below.right() > top.left(), "overlapping horizontally"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "back up"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "the top row stays put"); + // the filmstrip (one row): Down is the next photo + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + assert_eq!( + { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + }, + Some(1) + ); +} + +/// The search field is a shared text field: right-click ▸ Paste puts the clipboard's text in +/// and filters by it. +#[test] +fn the_search_field_pastes_from_its_menu() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + h.view.clipboard = "wedding".into(); + for (id, button) in [("field:search", "right"), ("field:search:paste", "left")] { + let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.search, "wedding"); + assert_eq!(h.app.session.filter.text, "wedding", "and the library is filtered by it"); +} + +/// Issue #1648: clearing the filters from the loupe's pill empties the search field too, so +/// the field never shows a search that is no longer applied. +#[test] +fn clearing_the_loupe_filters_empties_the_search_field() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "a"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.session.filter.text, "a", "filtered while typing"); + assert!(!h.app.session.visible_cloned().is_empty(), "the search finds photos, so the loupe has one to show"); + h.request("engine.execute", json!({"command": "view.detail"}), t); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "loupe:filters"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); +} + +/// Esc in the search field gives back the search from before the edit, and the filter with it. +#[test] +fn escape_in_the_search_field_gives_back_the_search() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "travel"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.session.filter.text, "travel", "filtered while typing"); + let r = h.request("ui.key", json!({"key": "Escape"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); +} + +#[test] +fn demo_grid_snapshot_has_ui_pixels() { + let t0 = Instant::now(); + let mut h = demo([1200.0, 760.0]); + // settle() can see a quiet spell before the first thumbnails land on a loaded machine + h.step_until(SETTLE, |h| h.app.renderer.thumb_textures() > 0); + let img = h.snapshot(SETTLE); + eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames()); + assert_eq!(img.size, [1200, 760]); + // not blank: many distinct colours + let mut colours: Vec = img.pixels.iter().map(|c| u32::from_le_bytes(c.to_array())).collect(); + colours.sort_unstable(); + colours.dedup(); + assert!(colours.len() > 200, "only {} colours", colours.len()); + // text in the top bar: bright pixels on the dark chrome + let top_h = crate::theme::Tokens::default().top_bar_h as usize; + let bright = img.pixels[..top_h * 1200].iter().filter(|c| c.r() > 150 && c.g() > 150 && c.b() > 150).count(); + assert!(bright > 30, "no text in the top bar ({bright} bright px)"); + // thumbnails arrived (photo textures were drawn) + assert!(h.app.renderer.thumb_textures() > 0); +} + +#[test] +fn snapshots_are_deterministic_and_control_requests_work() { + let shot = || { + let mut h = demo([900.0, 600.0]); + let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + // Compare only fully settled frames: a timed-out settle would compare half-rendered + // pictures and fail as a misleading pixel diff (seen on loaded CI machines). + assert!(h.settle(Duration::from_secs(300)), "the demo detail view did not settle within 300 s"); + h.paint() + }; + let (a, b) = (shot(), shot()); + assert_eq!(a.size, b.size); + // The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in + // the background), which differ by ≤ 1–2 LSB; anything more is a real difference. + let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count(); + assert_eq!(diff, 0, "{diff} pixels differ between two runs"); +} + +/// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance), +/// Survey (keys hit the active photo only), and auto-advance in Detail. +#[test] +fn compare_survey_and_auto_advance_by_keyboard() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating; + let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag; + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t); + let r = h.request("engine.execute", json!({"command": "view.compare"}), t); + assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); + // rating applies to the candidate (active) only + let before = rating(&h, vis[0]); + h.request("ui.key", json!({"key": "2"}), t); + assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2)); + // arrows move the candidate; the select stays + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("ui.key", json!({"key": "left"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[1]))); + // Shift+X: reject and advance to the next candidate + h.request("ui.key", json!({"key": "x", "shift": true}), t); + assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("engine.execute", json!({"command": "compare.swap"}), t); + assert_eq!(h.app.ui.compare, Some((vis[2], vis[0]))); + h.request("engine.execute", json!({"command": "compare.makeSelect"}), t); + assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0])); + // Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t); + let r = h.request("engine.execute", json!({"command": "view.survey"}), t); + assert_eq!(r["result"]["photos"], 3); + h.request("engine.execute", json!({"command": "view.autoAdvance"}), t); + assert!(h.app.ui.auto_advance); + h.request("ui.key", json!({"key": "5"}), t); + h.request("ui.key", json!({"key": "p"}), t); + assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); + assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1000, 700]); + // Detail: auto-advance moves to the next photo + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "u"}), t); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7])); + // Escape leaves the culling views for Detail + h.request("ui.set", json!({"view": "survey"}), t); + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); + // let in-flight renders finish: worker threads must not outlive the test process' TLS + h.settle(SETTLE); +} + +/// Naming a face in the loupe: point at an unnamed face, click its "Add name" label, type, Enter. Needs the +/// recognition runtime only for the background indexer, so the naming itself is tested whatever the build. +#[test] +fn naming_a_face_in_the_loupe() { + use crate::state::NameEdit; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.active().unwrap(); + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + // pointing at the box shows the invitation; clicking it opens the name box + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(matches!(&h.app.ui.name_edit, Some(NameEdit { index: 0, .. })), "{:?}", h.app.ui.name_edit); + // Escape closes it without naming anything + let r = h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.name_edit.is_none(), "{:?}", h.app.ui.name_edit); + assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name, None); + // open it again, type a name, Enter: the face is named, in one undo step + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); + h.step(); + h.step(); + h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); + h.step(); + h.step(); + let undo = h.app.session.undo.len(); + h.request("ui.text", json!({"text": "Ann"}), t); + h.step(); + h.request("ui.key", json!({"key": "enter"}), t); + h.step(); + h.step(); + assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name.as_deref(), Some("Ann")); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.ui.name_edit.is_none()); +} + +/// Adding a face model: the dialog shows the file's terms, the model is installed only once they are +/// accepted, and a file LightCraft cannot use only says why. +#[test] +fn adding_a_face_model_shows_its_terms_and_installs_only_once_accepted() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-facemodel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + let model = dir.join("Mine.onnx"); + std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); + let open = |h: &mut Headless, path: &std::path::Path| { + h.request("engine.execute", json!({"command": "dialog.faceModel", "params": {"path": path.to_string_lossy()}}), t) + }; + + // the dialog opens for the chosen file, with nothing accepted and nothing installed + assert_eq!(open(&mut h, &model)["ok"], true); + h.settle(SETTLE); + h.step(); + let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; + let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; + assert!(!accepted); + assert_eq!(info["kind"], "draft"); + assert!(!dir.join("models").exists()); + // OK does nothing yet + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); + assert!(!dir.join("models").exists()); + // ticking the box and confirming installs it + let r = h.request("ui.clickWidget", json!({"id": "check:faceModel.accept"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::FaceModel { accepted: true, .. }))); + // Consent is accepted, but no model is installed until confirmation. Keep this UI + // fixture on the CPU so it does not start an unrelated background GPU probe. + let models = dir.join("models"); + std::fs::create_dir_all(&models).unwrap(); + std::fs::write(models.join("settings.json"), br#"{"cpuOnly":true}"#).unwrap(); + assert_eq!(h.app.session.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], true); + let before_confirm = h.app.session.execute("faces.models.list", &json!({})).unwrap(); + assert!(before_confirm["models"].as_array().unwrap().iter().all(|m| m["installed"] != true), "{before_confirm}"); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let listed = h.app.run("faces.models.list", json!({})).unwrap(); + assert!(listed["models"].as_array().unwrap().iter().any(|m| m["installed"] == true && m["known"] == false), "{listed}"); + // installed, in use and recognition on: back in Settings ▸ Faces, where the scan can be watched + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); + assert_eq!(listed["enabled"], true); + h.app.ui.dialog = None; + + // a file that is not a usable model says why and offers no install + let junk = dir.join("junk.onnx"); + std::fs::write(&junk, b"not a model").unwrap(); + assert_eq!(open(&mut h, &junk)["ok"], true); + h.step(); + let Some(Dialog::FaceModel { info, .. }) = h.app.ui.dialog.clone() else { panic!("no dialog") }; + assert_eq!(info["kind"], "unsupported"); + // a path that does not exist is an error, not a dialog + h.app.ui.dialog = None; + assert_eq!(open(&mut h, &dir.join("nope.onnx"))["ok"], false); + assert!(h.app.ui.dialog.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Photo > Detect Faces without the detector offers the download: the terms first, nothing fetched before they are +/// accepted, and the model's row in Settings has its Download button. +#[test] +fn detect_faces_without_the_detector_offers_the_download_and_fetches_nothing_before_acceptance() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let dir = std::env::temp_dir().join(format!("lc-ui-detector-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + + let r = h.app.run("faces.detect", json!({})); + assert!(r.is_err(), "no detector yet"); + let Some(dlg) = h.app.ui.dialog.clone() else { panic!("the download was not offered") }; + let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; + assert!(!accepted); + assert_eq!((info["download"].as_str(), info["host"].as_str()), (Some("yunet-2023mar"), Some("github.com"))); + assert_eq!(info["model"]["licence"]["name"], "MIT"); + // OK without accepting the terms starts nothing + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); + assert!(h.app.session.face_downloads.snapshot().is_empty()); + assert!(!dir.join("models").join(".downloads").exists()); + assert!(h.app.caches.faces_dl_watch.is_empty()); + + // Settings > Faces shows the detector with a Download button + h.app.ui.dialog = Some(Dialog::Settings { tab: "faces".into() }); + h.step(); + // (the first request switches the widget list on, the next frame fills it) + h.request("ui.widgets", json!({}), Duration::from_secs(10)); + h.step(); + let w = h.request("ui.widgets", json!({"filter": "faces:"}), Duration::from_secs(10)); + assert!(w.to_string().contains("faces:download:yunet-2023mar"), "{w}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Pressing Download in Settings shows the model's terms first and fetches nothing until they are accepted; a build +/// that cannot run recognition models offers no download. The licence dialog replaces Settings, which it was opened from. +#[test] +fn download_shows_the_terms_first_and_fetches_nothing_until_accepted() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-facedl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "faces"}}), t); + h.settle(SETTLE); + h.step(); + let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; + let r = h.request("ui.clickWidget", json!({"id": "faces:download:sface-2021dec"}), t); + if !runtime { + assert_eq!(r["ok"], false, "a build that cannot run the model offers no download: {r}"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; + let Dialog::FaceModel { path, info, accepted } = &dlg else { panic!("the terms did not replace Settings: {dlg:?}") }; + assert_eq!((info["download"].as_str(), *accepted, path.as_str()), (Some("sface-2021dec"), false, "")); + assert_eq!(info["model"]["licence"]["commercial"], "unknown"); + // OK does nothing until the terms are accepted, and nothing has been fetched + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); + assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); + // cancelling leaves it that way + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Until face recognition is set up, the People view and the loupe's name box say so and offer the next step: with no +/// model, a button that opens Settings ▸ Faces; with a model installed but recognition off, one that switches it on. +#[test] +fn people_and_the_name_box_offer_to_set_face_recognition_up() { + use crate::state::{Dialog, ViewMode}; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-facesetup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // The setup offer tests recognition controls, not GPU attachment. Seed only this + // fixture's settings before any model can load, without detaching a shared detector. + let models = dir.join("models"); + std::fs::create_dir_all(&models).unwrap(); + std::fs::write(models.join("settings.json"), br#"{"cpuOnly":true}"#).unwrap(); + h.app.session.face_models_dir = Some(models); + assert_eq!(h.app.session.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], true); + let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; + let id = h.app.session.active().unwrap(); + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + let offers = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string().contains("\"faces:setup\""); + + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::People); + if !runtime { + assert!(!offers(&mut h), "a build that cannot run recognition offers nothing"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + // no model: the offer is there, and one click lands in Settings ▸ Faces + assert!(offers(&mut h)); + let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); + h.app.ui.dialog = None; + + // the loupe's name box offers it too, and its button leaves for Settings with the box closed + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); + h.step(); + h.step(); + h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); + h.step(); + h.step(); + assert!(h.app.ui.name_edit.is_some() && offers(&mut h), "the name box offers the setup"); + let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.name_edit.is_none()); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); + h.app.ui.dialog = None; + + // a model installed but recognition off: "Turn on" does it on the spot + let model = dir.join("Mine.onnx"); + std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); + let installed = h.app.run("faces.models.install", json!({"path": model.to_string_lossy(), "acknowledged": true, "activate": false})).unwrap(); + h.app.run("faces.models.select", json!({"id": installed["installed"]["id"]})).unwrap(); + h.app.caches.faces_epoch += 1; + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + assert!(offers(&mut h)); + assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], false); + let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], true); + assert_eq!(h.app.ui.dialog, None, "nothing to go to Settings for"); + h.step(); + assert!(!offers(&mut h), "once it is on the offer is gone"); + h.settle(SETTLE); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A click on a person's card opens their page, which shows only cropped faces (one per face, not per photo); a click +/// on one opens its photo; Back, the People button and Escape return to everyone. +#[test] +fn a_persons_page_shows_their_faces_and_back_returns_to_everyone() { + use crate::state::ViewMode; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); + let face = |x: f64, name: &str| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some(name.to_string()), + description: None, + }; + // Jane Doe twice in the first photo and once in the second; John Roe in the third + for (id, regions) in + [(ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]), (ids[1], vec![face(0.3, "Jane Doe")]), (ids[2], vec![face(0.3, "John Roe")])] + { + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = regions; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + } + let open_people = |h: &mut Headless| { + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + }; + open_people(&mut h); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); + // the card opens the page: a face tile for each of the three faces, not a tile per photo + let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); + let widgets = h.request("ui.widgets", json!({}), t).to_string(); + for (id, index) in [(ids[0], 0), (ids[0], 1), (ids[1], 0)] { + assert!(widgets.contains(&format!("\"person-face:{}:{index}\"", id.0)), "a tile for each face: {widgets}"); + } + assert!(!widgets.contains(&format!("\"person-face:{}:0\"", ids[2].0)), "someone else's face is not on the page"); + // a face opens its photo in the detail view + h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); + // Escape from that photo goes back to the page, not to the grid + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); + // the People button from a photo shows everyone, with the person last left one click away next to the title + h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + open_people(&mut h); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "people:last"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); + // a photo reached any other way goes back to the grid on Escape + h.request("engine.execute", json!({"command": "view.detail"}), t); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "person:back"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.person_page, None); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None), "Escape goes back to everyone"); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "John Roe"}}), t); + open_people(&mut h); + assert_eq!(h.app.ui.person_page, None, "the People button shows everyone"); + // a missing or empty name is refused + assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {}}), t)["ok"], false); + assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {"name": " "}}), t)["ok"], false); +} + +/// A person's More grid confirms a score-bounded batch through either ↑ or the focused tile's A key. Both +/// routes use one undo step and leave hidden, lower-score and already named faces alone. +#[test] +fn person_more_confirms_all_above_with_one_undo_step() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(7).collect(); + assert_eq!(ids.len(), 7); + let region = |name: Option<&str>| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: name.map(str::to_string), + description: None, + }; + for (id, name) in ids.iter().zip([Some("Jane Doe"), None, None, None, None, None, Some("John Roe")]) { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![region(name)]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.settle(SETTLE); + let page = crate::panels::person::PersonPage { + name: "Jane Doe".into(), + total: 1, + confirmed: vec![crate::panels::person::Face { photo: ids[0].0, index: 0, rect: region(None).rect, score: 0.0 }], + more: ids[1..6] + .iter() + .zip([0.95, 0.80, 0.80, 0.80, 0.60]) + .map(|(id, score)| crate::panels::person::Face { photo: id.0, index: 0, rect: region(None).rect, score }) + .collect(), + ready: true, + pending: 0, + }; + let set_page = |h: &mut Headless| { + h.app.caches.person_page = Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); + h.step(); + }; + h.app.ui.dismissed_faces.insert((ids[4].0, 0)); + set_page(&mut h); + let bulk_id = format!("more-face-above:{}:0", ids[2].0); + assert!(h.request("ui.widgets", json!({"filter": "more-face-above:"}), t).to_string().contains(&bulk_id)); + let undo = h.app.session.undo.len(); + let r = h.request("ui.clickWidget", json!({"id": bulk_id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let names = |h: &Headless| ids.iter().map(|id| h.app.session.catalog.photo(*id).unwrap().meta.regions[0].name.clone()).collect::>(); + assert_eq!( + names(&h), + vec![Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), None, None, Some("John Roe".into())] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + assert_eq!(names(&h), vec![Some("Jane Doe".into()), None, None, None, None, None, Some("John Roe".into())]); + // With no target A cannot name a face, even on this page. + h.request("ui.move", json!({"x": 1, "y": 1}), t); + h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique(("more-face-above", ids[2].0, 0_usize)))); + set_page(&mut h); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + // Text editing owns A even if a face was the last pointer target. + let search = h.request("ui.clickWidget", json!({"id": "field:search"}), t); + assert_eq!(search["ok"], true, "{search}"); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + // Hovering a tile is enough for the A key, without clicking or moving keyboard focus there. + let tile_id = format!("more-face:{}:0", ids[2].0); + let rect = h.app.widgets.iter().find(|(id, _)| id == &tile_id).unwrap().1; + h.request("ui.move", json!({"x": rect.center().x, "y": rect.center().y}), t); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1..4], [Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into())]); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + h.request("ui.move", json!({"x": 1, "y": 1}), t); + set_page(&mut h); + // Keyboard focus on the candidate makes the same batch action available without hovering it. + h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("more-face", ids[2].0, 0_usize)))); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!( + names(&h), + vec![Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), None, None, Some("John Roe".into())] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "view.photoGrid"}), t); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(h.app.session.undo.len(), undo + 1, "A outside the person page cannot name more faces"); +} + +/// The People view lists the unnamed faces below the named people: select some (a click each, or all), type a name, +/// press Enter, and they are all named at once, in one undo step; the new person appears among the named. +#[test] +fn unnamed_faces_are_selected_and_named_together() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); + let face = |x: f64, name: Option<&str>| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.2, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: name.map(str::to_string), + description: None, + }; + for (id, regions) in [ + (ids[0], vec![face(0.1, Some("Jane Doe")), face(0.5, None)]), + (ids[1], vec![face(0.3, None), face(0.6, None)]), + (ids[2], vec![face(0.3, None)]), + ] { + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = regions; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + h.step(); + let tiles = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); + // the named person is a card, and the four unnamed faces are tiles below + let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.step(); + h.step(); + assert_eq!(tiles(&mut h), 4); + // select two (nothing is named by selecting), and the naming bar appears + for (id, i) in [(ids[1], 0), (ids[2], 0)] { + let r = h.request("ui.clickWidget", json!({"id": format!("unnamed-face:{}:{i}", id.0)}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + } + assert_eq!(h.app.ui.unnamed_selected.len(), 2); + let bar = h.request("ui.widgets", json!({"filter": "unnamed:"}), t).to_string(); + assert!(bar.contains("unnamed:name") && bar.contains("unnamed:clear"), "{bar}"); + assert!(h.request("ui.widgets", json!({"filter": "field:unnamedName"}), t).to_string().contains("field:unnamedName")); + // type a name and press Enter: both faces are named, in one undo step, and the selection is gone + let undo = h.app.session.undo.len(); + h.request("ui.text", json!({"text": "Ann Example"}), t); + h.step(); + h.request("ui.key", json!({"key": "enter"}), t); + h.step(); + h.step(); + let named = |h: &Headless, id: lightcraft_catalog::PhotoId, i: usize| h.app.session.catalog.photo(id).unwrap().meta.regions[i].name.clone(); + assert_eq!((named(&h, ids[1], 0), named(&h, ids[2], 0)), (Some("Ann Example".to_string()), Some("Ann Example".to_string()))); + assert_eq!(named(&h, ids[1], 1), None, "a face that was not selected stays unnamed"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one step for both"); + assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty()); + h.settle(SETTLE); + h.step(); + assert_eq!(tiles(&mut h), 2, "the named faces left the unnamed list"); + assert_eq!(h.app.session.catalog.people().len(), 2, "and the new person is among the named"); + // select all, then clear: nothing is named + h.request("ui.clickWidget", json!({"id": "unnamed:selectAll"}), t); + h.step(); + h.step(); + assert_eq!(h.app.ui.unnamed_selected.len(), 2); + h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); + h.step(); + assert!(h.app.ui.unnamed_selected.is_empty()); + // undo gives the two faces back + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + assert_eq!(named(&h, ids[1], 0), None); +} + +/// A double click on an unnamed face opens its photo in the loupe, as in the grid (#1664); a single click still only +/// selects it, and Escape from that photo goes back to the People view. +#[test] +fn double_clicking_an_unnamed_face_opens_its_photo() { + use crate::state::ViewMode; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + for id in &ids { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0].0]}}), t); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + h.step(); + let tile = format!("unnamed-face:{}:0", ids[1].0); + // one click selects the face and stays in People + let r = h.request("ui.clickWidget", json!({"id": tile}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::People, Some(ids[0]))); + assert_eq!(h.app.ui.unnamed_selected.len(), 1); + h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); + // half a second of frames, so egui does not count the next two clicks as the third and fourth of one burst + for _ in 0..30 { + h.step(); + } + // two open the photo the face is in + let r = h.request("ui.clickWidget", json!({"id": tile, "count": 2}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); + assert_eq!(h.app.session.catalog.photo(ids[1]).unwrap().meta.regions[0].name, None, "nothing is named"); + // Escape goes back to the faces, not to the grid + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); +} + +/// The name field under the unnamed faces is a shared text field: right-click ▸ Paste puts the +/// clipboard's name in, and Esc leaves the field and clears the selection, as Clear does. +#[test] +fn the_unnamed_faces_name_field_pastes_and_escape_clears() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.catalog.photos().map(|p| p.id).next().unwrap(); + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.view.clipboard = "Wedding Guest".into(); + for (widget, button) in [ + (format!("unnamed-face:{}:0", id.0), "left"), + ("field:unnamedName".to_string(), "left"), + ("field:unnamedName".to_string(), "right"), + ("field:unnamedName:paste".to_string(), "left"), + ] { + let r = h.request("ui.clickWidget", json!({"id": widget, "button": button}), t); + assert_eq!(r["ok"], true, "{widget}: {r}"); + h.step(); + h.step(); + h.step(); + } + assert_eq!(h.app.ui.unnamed_name, "Wedding Guest"); + assert_eq!(h.app.ui.unnamed_selected.len(), 1, "still naming"); + h.request("ui.key", json!({"key": "Escape"}), t); + h.step(); + h.step(); + assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty(), "Esc cleared the selection and the name"); +} + +/// A screenful of faces larger than the picture cache's usual budget (96) is all kept: with a fixed budget the same few +/// tiles were evicted and re-requested every frame and stayed blank. +#[test] +fn a_screenful_of_small_faces_is_not_evicted_and_left_blank() { + use lightcraft_catalog::Op; + let mut h = demo([1500.0, 1000.0]); + let t = Duration::from_secs(20); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).collect(); + // seven unnamed faces in every photo of the demo library + for id in &ids { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = (0..7) + .map(|k| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.05 + 0.12 * k as f64, y0: 0.2, x1: 0.15 + 0.12 * k as f64, y1: 0.45 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }) + .collect(); + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + // the smallest faces, so a lot of them fit on the screen + h.request("ui.set", json!({"thumbSize": 90.0, "view": "people"}), t); + // Lay out the faces before waiting for their workers; a global settle also waits for + // unrelated work and hides a timed-out initial wait. + for _ in 0..4 { + h.step(); + } + h.step(); + let drawn = h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); + assert!(drawn > 96, "the test needs more faces on screen than the usual budget, got {drawn}"); + // the pictures are made and kept, well beyond the old budget (a few demo photos share a scene, so two faces can share a + // picture: the count is a little under the number of tiles) + let started = Instant::now(); + let timeout = Duration::from_secs(300); + while h.app.renderer.variant_textures() < drawn - 20 { + let left = timeout.saturating_sub(started.elapsed()); + assert!( + !left.is_zero(), + "variant renders did not finish: {} pictures for {drawn} faces, pending {:?}", + h.app.renderer.variant_textures(), + h.app.renderer.pending_slots() + ); + // Coverage instrumentation makes the procedural scenes expensive. Wait for actual + // worker progress instead of continuously rebuilding a full People-view frame. + h.app.renderer.wait_for_worker(left.min(Duration::from_millis(250))); + h.step(); + std::thread::sleep(Duration::from_millis(20)); + } + let kept = h.app.renderer.variant_textures(); + assert!(kept > 96 + 30, "{kept} pictures kept for {drawn} faces on screen"); + // and they stay: many more frames, and none is evicted to be asked for again + for _ in 0..30 { + h.step(); + } + assert!(h.app.renderer.variant_textures() >= kept, "{} pictures kept after more frames, {kept} before", h.app.renderer.variant_textures()); +} + +/// `ui.inspect` says how hard the face scan is allowed to work and whether the window counts as in front, so a slow +/// scan can be told from a stuck one. +#[test] +fn inspect_reports_the_face_scan_pace() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + h.step(); + let r = h.request("ui.inspect", json!({}), t); + let scan = &r["result"]["faceScan"]; + assert!(["pause", "light", "normal", "full"].contains(&scan["pace"].as_str().unwrap_or("")), "{scan}"); + assert!(scan["focused"].is_boolean() && scan["pending"].is_u64() && scan["indexed"].is_u64(), "{scan}"); +} + +/// Profile browser: live variant thumbnails, hover previews in the loupe without touching the +/// photo or its history, click applies, the star toggles the favourite. +#[test] +fn profile_browser_previews_on_hover_and_applies_on_click() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + let r = h.request("engine.execute", json!({"command": "panel.profiles"}), t); + assert_eq!(r["result"]["open"], true, "{r}"); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + h.step_until(SETTLE, |h| h.app.renderer.variant_textures() >= 6); + assert!(h.app.renderer.variant_textures() >= 6, "variant thumbnails rendered: {}", h.app.renderer.variant_textures()); + // hover: the loupe shows the look, nothing is committed + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + // the hover render can land after a quiet spell on a loaded machine (FreeBSD CI): wait for it + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render"); + assert_eq!(hover.photo, id); + let after = photo(&h); + assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()), "hover leaves the photo alone"); + assert_eq!(h.app.session.undo.len(), 0); + // moving away ends the preview + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.step(); + assert!(h.app.hover_preview.is_none()); + // click applies (one history step); the star toggles the favourite + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid"); + assert_eq!(photo(&h).history.len(), before.history.len() + 1); + h.request("ui.clickWidget", json!({"id": "profileStar:lc.portrait"}), t); + assert_eq!(h.app.session.profile_favorites, ["lc.portrait"]); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid", "the star doesn't apply"); + h.settle(SETTLE); +} + +/// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered. +#[test] +fn hovering_the_applied_profile_drops_the_last_hover() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t); + h.step(); + assert!(h.app.hover_preview.is_some(), "another profile previews"); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.step(); + assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one"); +} + +/// The preset applied to a photo is still marked (and its Amount slider back) after visiting +/// another photo, which shows none (issue #2392); an edit to the photo drops the mark. +#[test] +fn applied_preset_is_marked_again_on_returning_to_the_photo() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let a = h.app.session.active().unwrap(); + let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + let marked = |h: &Headless| { + let r = h.app.widgets.iter().find(|(id, _)| id == "presetApplied").map(|(_, r)| *r)?; + h.app.widgets.iter().find(|(id, wr)| id.starts_with("preset:") && *wr == r).map(|(id, _)| id.clone()) + }; + let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(marked(&h).as_deref(), Some("preset:lc.warm-glow")); + assert!(has(&h, "slider:presetAmount")); + for (photo, want) in [(b, None), (a, Some("preset:lc.warm-glow"))] { + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(marked(&h).as_deref(), want, "on photo {photo:?}"); + assert_eq!(has(&h, "slider:presetAmount"), want.is_some(), "the Amount slider follows the mark"); + } + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(marked(&h), None, "an edit after the preset drops the mark"); +} + +/// A preset applied while the active photo lies outside the selection changes only the +/// selection, so the active photo isn't marked as having it. +#[test] +fn applied_preset_is_not_marked_on_an_active_photo_outside_the_selection() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let a = h.app.session.active().unwrap(); + let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); + h.app.session.selection.ids = vec![b]; + h.app.session.selection.active = Some(a); + let before = h.app.session.develop_of(a).unwrap(); + let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.session.active(), Some(a)); + assert!(*h.app.session.develop_of(a).unwrap() == *before, "the active photo wasn't changed"); + assert!(!h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "nor marked"); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [b.0]}}), t); + h.settle(SETTLE); + h.step(); + assert!(h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "the selected photo is"); +} + +/// Presets column: resting on a preset previews it in the loupe without a history entry; +/// thumbnails are variant renders; Create Preset includes only the checked groups. +#[test] +fn preset_hover_previews_and_create_preset_groups() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let after = photo(&h); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering"); + assert!(h.app.session.undo.is_empty()); + // hovering down across group headers within the presets list retains the preview (no flicker) + h.request("ui.hoverWidget", json!({"id": "presetGroup:Creative"}), t); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); + // hovering another preset switches smoothly to it + h.request("ui.hoverWidget", json!({"id": "preset:lc.warm-glow"}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: Warm Glow")); + // thumbnails: variant textures for the visible presets + h.app.ui.preset_thumbs = true; + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.settle(SETTLE); + h.step(); + assert!(h.app.hover_preview.is_none()); + assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures()); + // Create Preset with a group checklist: untick Light, keep Effects + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t); + h.app.ui.dialog = Some(crate::state::Dialog::create_preset()); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t); + assert_eq!(r["ok"], true, "{r}"); + match &h.app.ui.dialog { + Some(crate::state::Dialog::CreatePreset { groups, .. }) => { + assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}"); + assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default"); + } + d => panic!("dialog: {d:?}"), + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone(); + assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings); + h.settle(SETTLE); +} + +/// A collection of thousands of presets (a Lightroom library imported whole) lists only the +/// rows in view: a few dozen widgets and thumbnails a frame, not one per preset (every frame +/// built all of them and froze the app for seconds); scrolling brings later rows in. +#[test] +fn presets_panel_with_thousands_of_presets_builds_only_visible_rows() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let many = (0..5000) + .map(|i| lightcraft_develop::Preset { + id: format!("user.big.{i}"), + name: format!("Look {i}"), + group: format!("Big {:02}", i / 500), + settings: json!({"light": {"exposure": (i % 20) as f64 / 10.0}}), + favorite: false, + builtin: false, + }) + .collect(); + assert_eq!(h.app.session.add_presets(many).len(), 5000); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.app.ui.preset_thumbs = true; + h.settle(SETTLE); + h.step(); + let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("preset:")).map(|(w, _)| w.clone()).collect::>(); + let first = rows(&h); + assert!((5..60).contains(&first.len()), "{} rows built", first.len()); + assert!(h.app.renderer.variant_textures() < 60, "{} thumbnails", h.app.renderer.variant_textures()); + assert!(h.app.widgets.iter().any(|(w, _)| w == "presetGroup:Big 09"), "every group header is listed"); + let started = std::time::Instant::now(); + h.step(); + eprintln!("presets panel frame with 5000 presets: {:?}", started.elapsed()); + // scroll far down over the list: later presets come into view + let r = h.request("ui.hoverWidget", json!({"id": first[first.len() / 2]}), t); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..20 { + h.request("ui.scroll", json!({"dy": -2000}), t); + h.step(); + } + h.step(); + let later = rows(&h); + assert!((5..60).contains(&later.len()), "{} rows built", later.len()); + let deep = later.iter().filter_map(|w| w.strip_prefix("preset:user.big.")).filter_map(|n| n.parse::().ok()).max(); + assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); +} + +/// With `label` and `labels` both set (a saved single-label filter, or an agent), the filter +/// bar lights only the labels that pass both, and a click starts from those, not their union. +#[test] +fn filter_bar_labels_from_label_and_labels() { + use lightcraft_catalog::ColorLabel; + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["red", "yellow"]})).unwrap(); + // a frame so the bar is laid out for the new filter before the click + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + assert_eq!(r["ok"], true, "{r}"); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Red, ColorLabel::Green])); + // nothing passes both: nothing is lit, so a click shows just that label + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["yellow"]})).unwrap(); + h.step(); + h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Green])); + h.settle(SETTLE); +} + +/// The filter bar drives `library.filter` and saves the view as a smart album. +#[test] +fn filter_bar_filters_and_saves_a_smart_album() { + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + let all = h.app.session.visible_cloned().len(); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + assert!(h.app.ui.filter_bar); + for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] { + let r = h.request("ui.clickWidget", json!({"id": w}), t); + assert_eq!(r["ok"], true, "{w}: {r}"); + } + let f = h.app.session.filter.clone(); + assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label"); + let n = h.app.session.visible_cloned().len(); + assert!(n > 0 && n < all); + h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. }))); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id; + assert_eq!(h.app.session.catalog.album_count(smart), n); + h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t); + assert_eq!(h.app.session.filter, Default::default()); + assert_eq!(h.app.session.visible_cloned().len(), all); + h.settle(SETTLE); +} + +/// The photo grid shows date headers (registered as `group:` widgets); clicking one +/// selects that day's photos; month headers when zoomed out; none when grouping is off. +#[test] +fn grid_groups_by_capture_date() { + let mut h = demo([1300.0, 800.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let groups = h.app.session.execute("library.groups", &json!({})).unwrap(); + let first = groups[0].clone(); + let key = first["key"].as_str().unwrap().to_string(); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{key}")}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.selection.ids.len() as u64, first["count"].as_u64().unwrap()); + // zoomed out: month headers + h.app.ui.thumb_size = 120.0; + h.settle(SETTLE); + let vis = h.app.session.visible_cloned(); + let month = h.app.session.catalog.date_runs(&vis, h.app.session.sort.key, lightcraft_catalog::GroupBy::Month); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); + assert_eq!(r["ok"], true, "{r}"); + h.request("engine.execute", json!({"command": "library.sort", "params": {"group": "none"}}), t); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); + assert_eq!(r["ok"], false, "no headers: {r}"); + h.settle(SETTLE); +} + +/// Keywords: the left-panel tree filters (children included), opens levels, the rename dialog +/// renames library-wide, and the Keywords panel adds a suggestion. +#[test] +fn keyword_list_filters_renames_and_suggests() { + // tall: the demo library's own keywords come first in the list + let mut h = demo([1300.0, 1800.0]); + // Host folders arrive asynchronously above Keywords. Keep this keyword fixture's + // geometry independent of their existence and the background stat timing. + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + h.app.ui.hidden_locations.extend( + ["Pictures", "Desktop", "Downloads", ""] + .map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }), + ); + } + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10)); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]})); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]})); + h.request("ui.set", json!({"leftPanel": true}), t); + // clicks land on last frame's layout: let the keyword list settle first (on a loaded machine a + // row could still move, and the click then hit its neighbour, e.g. "sunrise") + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel")); + assert_eq!(h.app.session.visible_cloned().len(), 3); + // open the level, filter by the child + h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.visible_cloned().len(), 2); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "travel".into(), to: "trips".into() }); + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy")); + assert_eq!(h.app.session.visible_cloned().len(), 2); + // Keywords panel: suggestions co-occurring with the photo's keywords + h.request("engine.execute", json!({"command": "library.clearFilter"}), t); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]})); + h.request("ui.set", json!({"right": "keywords"}), t); + assert!(h.settle(SETTLE), "keyword suggestions did not settle"); + let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string())); + h.settle(SETTLE); +} + +/// Rename Keyword's name field is a shared text field. It opens with the name selected, so +/// typing replaces it, and Return renames; right-click ▸ Paste works too; Esc cancels the +/// dialog and renames nothing. +#[test] +fn the_rename_keyword_field_selects_pastes_and_cancels() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let first = h.app.session.visible_cloned()[0].0; + h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["travel"]}}), t); + let keywords = |h: &Headless| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); + let open = |h: &mut Headless, name: &str| { + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: name.into(), to: name.into() }); + h.settle(SETTLE); + }; + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); + }; + // typing replaces the selected name + open(&mut h, "travel"); + ask(&mut h, "ui.text", json!({"text": "trips"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(keywords(&h).contains(&"trips".to_string()) && !keywords(&h).contains(&"travel".to_string()), "{:?}", keywords(&h)); + assert_eq!(h.app.ui.dialog, None); + // the menu's Paste + open(&mut h, "trips"); + h.view.clipboard = "weddings".into(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName:paste"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(keywords(&h).contains(&"weddings".to_string()), "{:?}", keywords(&h)); + // Esc cancels + open(&mut h, "weddings"); + ask(&mut h, "ui.text", json!({"text": "parties"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + assert_eq!(h.app.ui.dialog, None, "Esc closed the dialog"); + assert!(keywords(&h).contains(&"weddings".to_string()), "renamed nothing: {:?}", keywords(&h)); +} + +/// Return confirms the one-field dialogs: New Album, Rename Album, New Smart Album, Merge +/// Keywords and a text prompt (here: renaming an album). Their name opens selected, so typing +/// replaces it. Return did nothing: the field took the focus back on every frame, so egui never +/// reported the focus Return gives up. +#[test] +fn return_confirms_the_one_field_dialogs() { + use crate::state::Dialog; + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let first = h.app.session.visible_cloned()[0].0; + h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["holiday"]}}), t); + let album = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0); + let garden = album(&h, "Garden").expect("the demo's Garden album"); + let confirm = |h: &mut Headless, dialog: Dialog, typed: &str| { + h.app.ui.dialog = Some(dialog); + h.settle(SETTLE); + for (method, params) in [("ui.text", json!({"text": typed})), ("ui.key", json!({"key": "Enter"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); + }; + confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings"); + assert!(album(&h, "Weddings").is_some(), "New Album"); + confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); + assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); + confirm(&mut h, Dialog::NewSmartAlbum { name: "Smart Album".into(), parent: None }, "Everything"); + assert!(album(&h, "Everything").is_some(), "New Smart Album"); + confirm(&mut h, Dialog::MergeKeywords { from: vec!["holiday".into()], into: String::new() }, "travel"); + let keywords = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); + assert!(keywords.contains(&"travel".to_string()) && !keywords.contains(&"holiday".to_string()), "Merge Keywords: {keywords:?}"); + let prompt = Dialog::TextPrompt { + title: "Rename Album".into(), + hint: "Name".into(), + value: "Flowers".into(), + command: "album.rename".into(), + params: json!({"id": garden}), + key: "name".into(), + }; + confirm(&mut h, prompt, "Botanical"); + assert_eq!(album(&h, "Botanical"), Some(garden), "the text prompt"); +} + +/// The sidebar's file-system checks run on worker threads and add rows when they land, which +/// moves every row below them: `busy()` counts them, so `settle` waits for them before a test +/// reads widget positions (a click aimed at a stale rect hits the neighbouring row). +#[test] +fn settle_waits_for_file_system_checks() { + use std::sync::atomic::{AtomicBool, Ordering}; + static RELEASE: AtomicBool = AtomicBool::new(false); + fn slow_check(_: &str) -> bool { + let t0 = Instant::now(); + while !RELEASE.load(Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) { + std::thread::sleep(Duration::from_millis(1)); + } + true + } + let mut h = demo([800.0, 600.0]); + h.settle(SETTLE); + let check = |h: &mut Headless| { + let raw = HeadlessView::raw_input(h.size, 1.0, 0.0, vec![]); + let mut got = None; + h.view.run(raw, |ui| got = crate::panels::left::fs_cached(ui, "test-slow", "x", f64::INFINITY, slow_check)); + got + }; + assert_eq!(check(&mut h), None, "the answer is worked out off the UI thread"); + assert!(h.busy(), "a pending file-system check is pending work"); + RELEASE.store(true, Ordering::Release); + assert!(h.settle(SETTLE)); + assert!(!h.busy()); + assert_eq!(check(&mut h), Some(true)); +} + +/// Photo > Rename Photos…: the dialog previews and renames the selection. +#[test] +fn rename_dialog_renames_the_selection() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": vis}}), t); + let tree = h.request("ui.menu.tree", json!({}), t); + assert!(tree.to_string().contains("Rename 2 Photos…"), "live menu label"); + h.request("engine.execute", json!({"command": "dialog.rename", "params": {"template": "Trip-{seq:2}", "start": 5}}), t); + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let name = |id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().file_name.clone(); + assert!(name(vis[0]).starts_with("Trip-05."), "{}", name(vis[0])); + assert!(name(vis[1]).starts_with("Trip-06."), "{}", name(vis[1])); + h.settle(SETTLE); +} + +/// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows +/// progress, and reports the result when the files are written. +#[test] +fn export_dialog_keeps_its_width() { + // A dialog row sized as "available width − a guessed button width" made the auto-sized + // window grow a little every frame when the real button was wider (issue #8, Linux). + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.app.services.pick_folder = Some(Box::new(|| None)); + h.app.services.pick_files = Some(Box::new(Vec::new)); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + // the widest variant: watermark graphic and every optional row + if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() }; + wm.text.clear(); + opts.watermark = Some(wm); + } + let width = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + r["result"] + .as_array() + .and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")) + .map(|w| w["rect"][2].as_f64().unwrap()) + .expect("dialog on screen") + }; + for _ in 0..5 { + h.step(); + } + let w0 = width(&mut h); + for _ in 0..60 { + h.step(); + } + let w1 = width(&mut h); + assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}"); + assert!(w1 < 700.0, "{w1}"); +} + +/// With a watermark on, the Export dialog shows the first photo with the watermark on it. +#[test] +fn export_dialog_previews_the_watermark() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + let preview = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "image:exportWatermarkPreview")).map(|w| w["rect"].clone()) + }; + h.settle(SETTLE); + assert!(preview(&mut h).is_none(), "no preview without a watermark"); + if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + opts.watermark = Some(lightcraft_engine::export::Watermark { text: "© LightCraft".into(), ..Default::default() }); + } + let t0 = Instant::now(); + while preview(&mut h).is_none() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + let rect = preview(&mut h).expect("the watermark preview is shown"); + assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); +} + +/// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made +/// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export, +/// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept. +#[test] +fn export_dialog_drops_a_depth_the_format_lacks() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let opened = |h: &mut Headless, last: Value| { + h.app.ui.dialog = None; + h.app.session.last_export = Some(last); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + match &h.app.ui.dialog { + Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(), + _ => panic!("the export dialog is open"), + } + }; + let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(o.bit_depth, None); + lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export"); + assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32)); +} + +/// Review of #1966: a `path` whose extension names no format exports JPEG here, so its +/// `bitDepth` is checked against JPEG's, not let through to an 8-bit file. +#[test] +fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + for name in ["output", "output.xyz"] { + let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], false, "{name}: {r}"); + assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}"); + } + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still JPEG + let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path(); + assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG"); + let _ = std::fs::remove_dir_all(&out); +} + +#[test] +fn export_dialog_runs_in_the_background() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let w = written.clone(); + h.app.services.write_shared = Some(std::sync::Arc::new(move |p: &str, _: &[u8]| { + std::thread::sleep(Duration::from_millis(150)); // a slow disk: the UI must not wait for it + w.lock().unwrap().push(p.to_string()); + Ok(()) + })); + let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + h.settle(SETTLE); + if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(64); + *dir = "/lc-test-out".into(); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.export.is_some(), "running in the background"); + let running = h.request("ui.inspect", json!({}), t); + assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]); + // …and as a row in the activity stack (issue #345) + assert_eq!(running["result"]["activity"][0]["kind"], "export", "{}", running["result"]["activity"]); + let t0 = Instant::now(); + while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { + h.step(); + } + assert!(h.app.export.is_none(), "finished"); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 3, "{w:?}"); + assert!(w.iter().all(|p| p.starts_with("/lc-test-out/"))); + let after = h.request("ui.inspect", json!({}), t)["result"].clone(); + assert_eq!(after["activity"], json!([]), "the row is gone"); + let last = after["export"]["last"].clone(); + assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}"); + assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64)); +} + +/// Issue #236: the Export dialog's Add to Apple Photos is there on macOS only; there the +/// background export hands the files it wrote to Photos (a fake runner: tests never start +/// osascript or Photos) and the toast says how it went. +#[test] +fn export_dialog_adds_to_apple_photos_on_macos() { + let mut h = demo([1200.0, 1100.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-photos-dialog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); + let c = calls.clone(); + h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { + c.lock().unwrap().push(args.to_vec()); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "A/L0/001\nB/L0/001\n".into(), stderr: String::new() }) + })); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + h.settle(SETTLE); + let shown = h.request("ui.widgets", json!({"filter": "exportAddToPhotos"}), t)["result"].as_array().map_or(0, Vec::len); + assert_eq!(shown > 0, cfg!(target_os = "macos"), "the option is offered where Photos is"); + if !cfg!(target_os = "macos") { + return; + } + h.request("ui.clickWidget", json!({"id": "exportAddToPhotos"}), t); + h.settle(SETTLE); + if let Some(crate::state::Dialog::Export { opts, full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + assert!(opts.add_to_photos, "the checkbox turns it on"); + opts.photos_album = "Trip “2026”".into(); + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(48); + *dir = out.to_string_lossy().into_owned(); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let t0 = Instant::now(); + while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { + h.step(); + } + let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone(); + let written: Vec = last["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect(); + assert_eq!(written.len(), 2, "{last}"); + assert_eq!(last["applePhotos"]["imported"], 2, "{last}"); + assert_eq!(last["applePhotos"]["album"], "Trip “2026”"); + let args = calls.lock().unwrap().clone(); + assert_eq!(args.len(), 1); + assert_eq!(args[0][args[0].len() - 2..], written[..], "the files written go to Photos"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("2 added to Apple Photos album “Trip “2026””"), "{toast}"); + // Export with Previous repeats it + assert_eq!(h.app.session.last_export.as_ref().unwrap()["addToPhotos"], true); + let _ = std::fs::remove_dir_all(&out); +} + +/// Codex review of #643: the progress line while Photos imports was translated on the export's +/// worker thread, whose language is always English. +#[test] +fn the_photos_progress_line_is_in_the_users_language() { + if !cfg!(target_os = "macos") { + return; + } + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-photos-language-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + h.app.ui.language = crate::i18n::Locale::De; + crate::i18n::set_language(crate::i18n::Locale::De); + h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + let (release, held) = std::sync::mpsc::channel::<()>(); + let held = std::sync::Mutex::new(held); + let (entered, photos_asked) = std::sync::mpsc::channel::<()>(); + let entered = std::sync::Mutex::new(entered); + h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { + let _ = entered.lock().unwrap().send(()); + let _ = held.lock().unwrap().recv_timeout(Duration::from_secs(30)); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let ids: Vec = h.app.session.visible_cloned().iter().take(1).map(|p| p.0).collect(); + let params = json!({"ids": ids, "dir": out, "longEdge": 48, "addToPhotos": true, "background": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + // Photos has been asked, and the window hasn't drawn a frame since (Codex review of #643): + // the export's row already has no ✕, and a cancel is refused rather than half-honoured + photos_asked.recv_timeout(Duration::from_secs(60)).expect("the export asks Photos"); + let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); + assert!(!row.cancellable, "{row:?}"); + assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked"); + let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default(); + assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…")); + // the row says so on the next frame (issue #345) + h.step(); + let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); + assert_eq!((row.detail.as_str(), row.cancellable), ("Wird zu Apple Fotos hinzugefügt…", false), "{row:?}"); + release.send(()).unwrap(); + let t0 = Instant::now(); + while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(90) { + h.step(); + } + let last = h.app.last_export_result.clone().expect("the export reports"); + assert_eq!((last["cancelled"].as_bool(), last["applePhotos"]["imported"].as_u64()), (Some(false), Some(1)), "{last}"); + crate::i18n::set_language(crate::i18n::Locale::En); + let _ = std::fs::remove_dir_all(&out); +} + +/// Issue #236 review: control requests run on the UI thread, which must not wait minutes for +/// Photos. `export.addToPhotos` and `app.export {addToPhotos}` without `background` answer at +/// once with the running job; the app announces the outcome when Photos is done. +#[test] +fn a_slow_photos_import_never_blocks_the_window() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(30); + let out = std::env::temp_dir().join(format!("lc-photos-slow-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + let file = out.join("a.jpg"); + std::fs::write(&file, b"jpeg").unwrap(); + h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = std::sync::Mutex::new(wait); + h.app.session.apple_photos_background = true; // as the desktop app sets it + h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { + // a regression (Photos on the UI thread) fails the timing below instead of hanging + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let n = args.len() - args.iter().position(|a| a == lightcraft_engine::apple_photos::MARKER).unwrap() - 2; + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + })); + let t0 = Instant::now(); + let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t); + assert!(t0.elapsed() < Duration::from_secs(5), "answered at once: {r}"); + assert_eq!(r["result"]["running"], true, "{r}"); + go.send(()).unwrap(); + while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Apple Photos")) { + assert!(t0.elapsed() < Duration::from_secs(20), "the outcome is announced"); + h.step(); + } + assert_eq!(h.app.ui.toast.as_ref().unwrap().0, "1 added to Apple Photos album “Slow”"); + if cfg!(target_os = "macos") { + let t1 = Instant::now(); + let p = json!({"dir": out.to_string_lossy(), "longEdge": 32, "addToPhotos": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert!(t1.elapsed() < Duration::from_secs(10), "the export answers without waiting for Photos: {r}"); + assert_eq!(r["result"]["applePhotos"]["running"], true, "{r}"); + assert_eq!(r["result"]["applePhotos"]["job"], 2, "{r}"); + go.send(()).unwrap(); + while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("1 added to Apple Photos") && !t.0.contains("Slow")) { + assert!(t1.elapsed() < Duration::from_secs(20), "the outcome is announced"); + h.step(); + } + } + let _ = std::fs::remove_dir_all(&out); +} + +/// Review of #236: a headless run (snapshot scripts) ending while an import it started with +/// `wait: false` is still running waits for it, on every exit path (dropping the `Headless`). +#[test] +fn a_headless_run_waits_for_its_photos_import_before_ending() { + let mut h = demo([800.0, 600.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-photos-headless-drop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.jpg"); + std::fs::write(&file, b"jpeg").unwrap(); + let marker = dir.join("imported"); + let m = marker.clone(); + // Photos "imports" when `go` arrives (at most 20 s): released 0.8 s into the drop below + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = std::sync::Mutex::new(wait); + h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + std::fs::write(&m, b"done").unwrap(); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t); + assert_eq!(r["result"]["running"], true, "{r}"); + assert!(!marker.exists(), "still importing"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(800)); + let _ = go.send(()); + }); + drop(h); + assert!(marker.exists(), "the import finished before the run ended"); + release.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review of #236: a snapshot that ends (an error in its script, say) while its background +/// export is still writing waits through the writing and then the import the export starts. +#[test] +fn a_headless_run_waits_for_its_exports_photos_import() { + if !cfg!(target_os = "macos") { + return; // addToPhotos is refused before exporting elsewhere + } + let mut h = demo([800.0, 600.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-photos-headless-export-drop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let marker = dir.join("imported"); + h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| { + std::thread::sleep(Duration::from_millis(300)); // a slow disk: still writing at the drop + lightcraft_engine::export::write_file(p, bytes) + })); + let m = marker.clone(); + h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { + std::thread::sleep(Duration::from_millis(500)); + std::fs::write(&m, b"done").unwrap(); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let p = json!({"dir": dir.join("out").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "background": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.apple_photos_imports.running().is_some(), "reserved while writing"); + drop(h); + assert!(marker.exists(), "the export's import finished before the run ended"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review of #236: a background export adding to Photos holds the import from before it +/// writes. While its files are being written, `export.addToPhotos` is refused with the +/// export's job number; the export then adds its files under that job. +#[test] +fn a_background_export_holds_photos_while_it_writes() { + if !cfg!(target_os = "macos") { + return; // addToPhotos is refused before exporting elsewhere + } + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-photos-hold-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + let other = out.join("other.jpg"); + std::fs::write(&other, b"jpeg").unwrap(); + // the writer waits for `go`: the export is still writing while we try another import + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = std::sync::Mutex::new(wait); + h.app.services.write_shared = Some(Arc::new(move |p: &str, bytes: &[u8]| { + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + lightcraft_engine::export::write_file(p, bytes) + })); + let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); + let c = calls.clone(); + h.app.session.apple_photos_background = true; + h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { + c.lock().unwrap().push(args.to_vec()); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let p = json!({"dir": out.join("export").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "Held", "background": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.export.is_some(), "writing in the background"); + let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [other.to_string_lossy()]}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap().contains("An export is about to add its files to Apple Photos (import 1)"), "{r}"); + go.send(()).unwrap(); + let t0 = Instant::now(); + while h.app.export.is_some() { + assert!(t0.elapsed() < Duration::from_secs(20), "the export finishes"); + h.step(); + } + let last = h.app.last_export_result.clone().unwrap(); + assert_eq!((last["applePhotos"]["job"].as_u64(), last["applePhotos"]["imported"].as_u64()), (Some(1), Some(1)), "{last}"); + let args = calls.lock().unwrap().clone(); + assert_eq!(args.len(), 1, "only the export's files went to Photos"); + assert!(args[0].last().unwrap().contains("export"), "{:?}", args[0].last()); + let _ = std::fs::remove_dir_all(&out); +} + +/// Dragging grid photos onto an album row adds them to the album. +#[test] +fn drag_photos_onto_an_album() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "view.leftPanel"}), t); + let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Dropped"}}), t); + let album = r["result"]["id"].as_u64().unwrap_or_else(|| panic!("{r}")); + h.settle(SETTLE); + let rect = |h: &mut Headless, id: &str| { + let w = h.request("ui.widgets", json!({"filter": id}), t); + let r = + w["result"].as_array().and_then(|a| a.iter().find(|x| x["id"] == id)).map(|x| x["rect"].clone()).unwrap_or_else(|| panic!("{id}: {w}")); + let f = |i: usize| r[i].as_f64().unwrap(); + (f(0) + f(2) / 2.0, f(1) + f(3) / 2.0) + }; + let first = h.app.session.visible_cloned()[0].0; + let (x, y) = rect(&mut h, &format!("thumb:{first}")); + let (tx, ty) = rect(&mut h, &format!("source:album:{album}")); + let r = h.request("ui.drag", json!({"x": x, "y": y, "toX": tx, "toY": ty, "steps": 12}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let members = h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.clone(); + assert_eq!(members, vec![lightcraft_catalog::PhotoId(first)]); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); +} + +/// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge +/// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos +/// show none of it. +#[test] +fn preview_only_raw_shows_badge_and_notices() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let vis = h.app.session.visible_cloned(); + let (po, other) = (vis[0], vis[1]); + let ph = h.app.session.catalog.photo(po).unwrap().clone(); + h.app + .session + .catalog + .apply(lightcraft_catalog::Op::SetContent { + id: po, + width: ph.width, + height: ph.height, + file_size: ph.file_size, + content_hash: ph.content_hash.clone(), + preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), + preview_checked: lightcraft_catalog::APP_VERSION.into(), + }) + .unwrap(); + h.settle(SETTLE); + let ids = |h: &mut Headless, filter: &str| -> Vec { + let w = h.request("ui.widgets", json!({"filter": filter}), t); + w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect() + }; + let badges = ids(&mut h, "badge:previewOnly:"); + assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge"); + // the loupe and the Edit panel + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + let n = ids(&mut h, "notice:previewOnly:"); + assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}"); + // with a Reload from Disk button (issue #662) + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:edit".to_string()]); + // Info + h.app.ui.right = crate::state::RightPanel::Info; + h.settle(SETTLE); + assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string())); + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:info".to_string()]); + // …which re-reads the file and says what came of it (a demo photo has none to re-read) + let r = h.request("ui.clickWidget", json!({"id": "button:reloadPreviewOnly:info"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.toast.clone().map(|x| x.0).as_deref(), Some("This raw variant still can't be decoded")); + // a regular photo: no notice + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + assert!(ids(&mut h, "notice:previewOnly:").is_empty()); +} + +#[test] +fn crop_default_browsing_is_read_only_and_drag_is_one_edit() { + let mut h = demo([1200.0, 800.0]); + let photos: Vec<_> = h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); + h.app.run("library.select", json!({"ids": [photos[0].0]})).unwrap(); + h.app.ui.right = crate::state::RightPanel::Crop; + h.app.run("crop.aspect", json!({"aspect": "1x1"})).unwrap(); + let saved = serde_json::to_value(&h.app.ui).unwrap(); + for &photo in &photos[1..3] { + h.app.run("library.select", json!({"ids": [photo.0]})).unwrap(); + let before = h.app.session.catalog.photo(photo).unwrap().clone(); + let undo = h.app.session.undo.len(); + h.settle(SETTLE); + let after = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len()); + assert!(!after.is_edited()); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); + } + let photo = photos[2]; + let before = h.app.session.develop_of(photo).unwrap(); + let undo = h.app.session.undo.len(); + h.app.run("develop.beginInteraction", json!({"label": "Crop"})).unwrap(); + h.app.begin_crop().unwrap(); + h.app.run("crop.drag", json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [0.7, 0.7]})).unwrap(); + h.app.run("develop.endInteraction", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap().crop.aspect, Some((100, 100))); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.ui = serde_json::from_value(saved).unwrap(); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.ui.crop_default_aspect = Some(json!("original")); + let p = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(h.app.crop_tool_aspect(), Some((p.width * 100, p.height * 100))); + h.settle(SETTLE); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); +} + +/// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. +#[test] +fn crop_keys() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.crop"}), t); + assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Thirds); + let mask_overlay = h.app.ui.mask_overlay; + h.request("ui.key", json!({"key": "O"}), t); + assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Grid); + h.request("ui.key", json!({"key": "O", "shift": true}), t); + assert_eq!(h.app.ui.crop_overlay_orient, 1); + assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); + // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks + let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); + assert!(locked(&h)); + h.request("ui.key", json!({"key": "A"}), t); + assert!(!locked(&h)); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + h.request("ui.key", json!({"key": "A"}), t); + assert!(locked(&h)); + assert!(!h.app.ui.visualize_spots); +} + +/// Bring a right-panel field into the visible panel before clicking it. The Info panel is a +/// scroll area: 0.37's roomier text rows push the place fields below a 1000-point window, and a +/// click at an off-screen rect would leave the previous field focused. +fn click_field_in_view(h: &mut Headless, key: &str, timeout: Duration) { + let id = format!("field:{key}"); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:right_panel").map(|(_, r)| *r).expect("the right panel is laid out"); + let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out"); + if !panel.contains(rect.center()) { + let dy = (panel.center().y - rect.center().y).clamp(-4000.0, 4000.0); + assert_eq!(h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), timeout)["ok"], true); + assert_eq!(h.request("ui.scroll", json!({"dy": dy}), timeout)["ok"], true); + h.settle(timeout); + } + let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out"); + assert!(panel.contains(rect.center()), "the field must be scrolled into view before clicking: {rect:?} in {panel:?}"); + let r = h.request("ui.clickWidget", json!({"id": id}), timeout); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Info panel: typing into a field survives frames and is saved when it loses focus; the new +/// accessibility and place fields reach the photo. +#[test] +fn info_fields_keep_typing_and_save() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + h.settle(SETTLE); + for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] { + click_field_in_view(&mut h, key, t); + h.request("ui.key", json!({"key": "A", "cmd": true}), t); + h.request("ui.text", json!({"text": text}), t); + h.step(); + h.step(); + // still being edited: not saved yet, not lost either + h.request("ui.key", json!({"key": "Tab"}), t); + h.settle(Duration::from_secs(5)); + } + let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta; + assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt")); + assert_eq!(m.usage_terms, "Editorial use only"); + // the copyright status picker is on the panel too + let w = h.request("ui.widgets", json!({"filter": "copyrightStatus"}), t); + assert!(w["result"].to_string().contains("field:copyrightStatus"), "{w}"); +} + +/// Info panel fields are shared text fields. The caption takes several lines: Return starts a +/// new one and only leaving the field saves it. Esc gives up an edit (the title stays as it +/// was, nothing saved), and right-click ▸ Paste works there too. +#[test] +fn info_fields_take_lines_esc_and_the_menu() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + h.settle(SETTLE); + let meta = |h: &Headless| h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta.clone(); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let (title, undo) = (meta(&h).title, h.app.session.undo.len()); + // the caption: two lines, saved on leaving + ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); + ask(&mut h, "ui.text", json!({"text": "Harbour at dusk"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + ask(&mut h, "ui.text", json!({"text": "Lisbon, 2026"})); + assert_eq!(h.app.session.undo.len(), undo, "Return in the caption saves nothing yet"); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + assert_eq!(meta(&h).caption, "Harbour at dusk\nLisbon, 2026"); + // the title: Esc gives the edit up + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.text", json!({"text": " (draft)"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + h.settle(SETTLE); + assert_eq!((meta(&h).title, h.app.session.undo.len()), (title, undo), "Esc saved nothing"); + // the menu + h.view.clipboard = "Wedding in Sintra".into(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title:paste"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert_eq!(meta(&h).title, "Wedding in Sintra"); +} + +/// With several photos selected the Info panel offers Target Photo / Selected Photos. Target +/// Photo (the default) shows the active photo's values. Selected Photos leaves a field whose +/// values differ empty, so what is typed there is what every selected photo gets, and keeps +/// the value of a field they share; leaving a mixed field untouched saves nothing. +#[test] +fn info_shows_metadata_for_the_selected_photos() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + r + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params}))["result"].clone() + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let (a, b) = (lightcraft_catalog::PhotoId(ids[0]), lightcraft_catalog::PhotoId(ids[1])); + let meta = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn", "copyrightStatus": "copyrighted"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "creator": "Ana"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + h.settle(SETTLE); + let buttons = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "infoFor"}), t)["result"].to_string(); + assert!(buttons(&mut h).contains("button:infoFor-selected"), "the choice shows with two photos selected"); + assert!(h.app.ui.settings.info_selected_photos, "Selected Photos by default"); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn!"})); + // Target Photo and back to Selected Photos, from their buttons + ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-target"})); + assert!(!h.app.ui.settings.info_selected_photos); + ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-selected"})); + assert!(h.app.ui.settings.info_selected_photos); + // a mixed field left untouched saves nothing + let undo = h.app.session.undo.len(); + click_field_in_view(&mut h, "title", t); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo)); + // neither does switching the choice while a field is being edited, either way + for on in [false, true] { + click_field_in_view(&mut h, "title", t); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": on})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo), "{on}"); + } + // the mixed title starts empty; the shared creator keeps its value + for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] { + click_field_in_view(&mut h, key, t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": typed})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let got = |h: &Headless, id| if key == "title" { meta(h, id).title } else { meta(h, id).creator }; + assert_eq!((got(&h, a), got(&h, b)), (want.to_string(), want.to_string()), "{key}"); + } + // a mixed field typed in and left empty clears it on every selected photo + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Old"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Other"})); + h.settle(SETTLE); + click_field_in_view(&mut h, "caption", t); + ask(&mut h, "ui.text", json!({"text": "x"})); + ask(&mut h, "ui.key", json!({"key": "Backspace"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).caption, meta(&h, b).caption), (String::new(), String::new())); + // GPS: one photo's place and Show on Map only while the selected photos share it + let map = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "showOnMap"}), t)["result"].to_string().contains("showOnMap"); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "gps": [41.15, -8.61]})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "gps": [38.72, -9.14]})); + h.settle(SETTLE); + assert!(!map(&mut h), "places differ"); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "gps": [41.15, -8.61]})); + h.settle(SETTLE); + assert!(map(&mut h), "a shared place"); + // the command: refuses a value that isn't true or false, and turns Target Photo back on + let r = h.request("engine.execute", json!({"command": "view.infoSelectedPhotos", "params": {"on": "yes"}}), t); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}))["on"], false); + assert!(!h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({}))["on"], true, "no params toggles"); + // the choice is saved with the app settings; settings saved before it read as Selected Photos + exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(saved["infoSelectedPhotos"], false); + let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap(); + assert!(!read.info_selected_photos); + let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap(); + assert!(old.info_selected_photos); + // one photo selected: no choice to make + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + h.settle(SETTLE); + assert!(!buttons(&mut h).contains("infoFor-"), "{}", buttons(&mut h)); +} + +/// With several photos selected and the settings as they come, an edit to a field where the +/// photos differ starts from an empty field: the photos get what was typed, not the active +/// photo's value with the typing added. +#[test] +fn info_edits_of_a_multi_selection_start_from_mixed_fields_by_default() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + h.settle(SETTLE); + click_field_in_view(&mut h, "caption", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "Harbor"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".into())); +} + +/// Target Photo edits the photo it shows: with several photos selected, a field's edit is +/// saved to the active photo alone. +#[test] +fn info_target_photo_edits_only_the_photo_it_shows() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); + h.settle(SETTLE); + click_field_in_view(&mut h, "caption", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((caption(&h, 0), caption(&h, 1)), ("Dawn!".into(), "Dusk".into())); +} + +/// The fields the Info panel marks as mixed: each text field, the copyright status and GPS +/// where a selected photo differs from the photo the panel shows, and none for one photo. +#[test] +fn info_mixed_fields_are_the_ones_that_differ() { + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let all = |key: &str, v: serde_json::Value| json!({"ids": ids, key: v}); + // (every field the panel compares starts the same on the three photos) + let fields = + ["title", "caption", "altText", "extendedDescription", "copyright", "usageTerms", "copyrightUrl", "creator", "location", "state", "country"]; + for key in fields { + s.execute("photo.setMeta", &all(key, json!(""))).unwrap(); + } + for (key, v) in [("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))] { + s.execute("photo.setMeta", &all(key, v)).unwrap(); + } + let photos: Vec = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect(); + let shown = s.catalog.photo(photos[0]).unwrap().meta.clone(); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos, &shown).is_empty()); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos[..1], &shown).is_empty()); + s.execute("photo.setMeta", &json!({"ids": [ids[2]], "city": "Lisbon", "caption": "x", "gps": [38.72, -9.14]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "copyrightStatus": "publicDomain"})).unwrap(); + let mut mixed = crate::panels::right::mixed_meta(&s.catalog, &photos, &shown); + mixed.sort_unstable(); + assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]); +} + +/// Selected Photos with the active photo outside the selection: the fields show what the +/// selected photos share, not the active photo's values. +#[test] +fn info_selected_photos_ignore_an_active_photo_outside_the_selection() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let title = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.title.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0], ids[1]], "title": "Same"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[2]], "title": "Other"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[2]})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": true})); + h.settle(SETTLE); + click_field_in_view(&mut h, "title", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into())); +} + +/// An Info field saves to the photos selected when its edit started, also when the selection +/// changes while the field is being edited. +#[test] +fn info_field_saves_to_the_photos_selected_when_the_edit_started() { + // (tall enough for the GPS field to show without scrolling) + let mut h = demo([1300.0, 1800.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(4).map(|p| p.0).collect(); + let meta = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "title": "Same", "gps": ""})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + click_field_in_view(&mut h, "title", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + exec(&mut h, "library.select", json!({"ids": [ids[2]]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).title, meta(&h, 1).title, meta(&h, 2).title), ("Same!".into(), "Same!".into(), "Same".into())); + // GPS too (the new selection is two photos as well, so the panel keeps its layout) + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + click_field_in_view(&mut h, "gps", t); + ask(&mut h, "ui.text", json!({"text": "41.15, -8.61"})); + exec(&mut h, "library.select", json!({"ids": [ids[2], ids[3]], "active": ids[2]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let gps: Vec = (0..4).map(|i| meta(&h, i).gps.is_some()).collect(); + assert_eq!(gps, [true, true, false, false]); + // Esc gives a GPS edit up + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + let before = meta(&h, 0).gps; + click_field_in_view(&mut h, "gps", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "5"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).gps, meta(&h, 1).gps), (before, before)); +} + +/// Local: a folder's photos show without joining the library; the breadcrumb, Include +/// subfolders and Add to My Photos work from the grid header. +#[test] +fn browse_a_local_folder() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("inner")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() { + let mut img = img.clone(); + img.data[0][0] = i as u8; // different bytes per file + std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + } + let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count(); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); + // the folder is read in the background: the view switches at once, the photos follow + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::Folder); + h.settle(SETTLE); + assert!(h.app.scan.is_none() && h.app.import.is_none()); + assert_eq!(h.app.session.visible_cloned().len(), 1); + assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy"); + // this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried + let id = h.app.session.visible_cloned()[0]; + assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some()); + let done = h.app.renderer.completed; + for _ in 0..30 { + h.step(); + } + assert_eq!(h.app.renderer.completed, done, "no retry loop"); + let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t); + assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 2); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2); + // Truncated ancestors remain clickable and still open the original, unabridged path. + let inner = dir.join("inner"); + h.request("engine.execute", json!({"command": "library.browse", "params": {"path": inner.to_string_lossy()}}), t); + h.settle(SETTLE); + let parent_index = dir.to_string_lossy().split(['/', '\\']).filter(|p| !p.is_empty()).count() - 1; + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("crumb:{parent_index}")}), t)["ok"], true); + h.settle(SETTLE); + // the same folder, whatever the platform's separators (issue #538) + assert_eq!(std::path::Path::new(&h.app.session.browse.as_ref().unwrap().path), dir.as_path()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// 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 +/// open down to the hidden folder and push the restore row out of view. +#[test] +fn local_location_can_be_hidden_and_restored() { + let size = [1300.0, 900.0]; + let mut h = demo(size); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id())); + 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"); + 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}"))); + } + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); +} + +/// Local: a kept folder stays a root while its subfolders (and other locations) are +/// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and +/// the kept folders survive a save/load of the UI state (a restart). +#[test] +fn kept_local_root_stays_while_browsing_below_and_elsewhere() { + let mut h = demo([1300.0, 1400.0]); + let t = Duration::from_secs(10); + let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + // joined by component: the sidebar's ids spell paths with the platform's separator + let year = base.join("Photos").join("2026"); + let (photos, day1, day2, other) = (s(base.join("Photos")), s(year.join("20260101")), s(year.join("20260114")), s(base.join("Other"))); + h.hide_home_above(&base); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + let rects = |h: &mut Headless| -> std::collections::HashMap { + let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t); + w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect() + }; + h.request("ui.set", json!({"leftPanel": true}), t); + // Home may also own this temporary directory. Keep the fixture tree + // independent of the user's real folders and their async listings. + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + assert_eq!(exec(&mut h, "local.hide", json!({"path": home}))["ok"], true); + } + assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true); + assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1)); + exec(&mut h, "library.browse", json!({"path": day1})); + h.settle(SETTLE); + h.step(); + let row = format!("source:local:{day2}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the sibling listing did not arrive"); + let r = rects(&mut h); + assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}"); + assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}"); + let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]); + assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed"); + // another location: the kept root stays listed + assert_eq!(exec(&mut h, "library.browse", json!({"path": other}))["ok"], true); + h.settle(SETTLE); + h.step(); + let row = format!("source:local:{other}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the other location did not arrive"); + let r = rects(&mut h); + assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}"); + // restart: the kept roots come back with the saved UI state + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(back.local_roots, vec![photos.clone()]); + let _ = std::fs::remove_dir_all(&base); + h.settle(SETTLE); +} + +/// Versions panel: resting on a version previews it in the loupe without changing the photo; +/// a click restores it. +#[test] +fn versions_preview_on_hover_and_restore_on_click() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + h.request("ui.set", json!({"view": "detail", "right": "versions"}), t); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0}})); + exec(&mut h, "version.create", json!({"name": "Bright"})); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": -1.0}})); + h.settle(SETTLE); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert_eq!(h.request("ui.hoverWidget", json!({"id": "version:0"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.settings.light.exposure), Some(1.0)); + assert_eq!(exposure(&h), -1.0, "hovering changes nothing"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "version:0"}), t)["ok"], true); + h.settle(Duration::from_secs(5)); + assert_eq!(exposure(&h), 1.0); +} + +/// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo. +#[test] +fn slider_nudge_and_pick_advance() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true); + h.request("ui.key", json!({"key": "Up"}), t); + h.request("ui.key", json!({"key": "Up", "shift": true}), t); + assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h)); + h.request("ui.key", json!({"key": "Down"}), t); + assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h)); + let first = h.app.session.active().unwrap(); + h.request("ui.key", json!({"key": "Z", "shift": true}), t); + assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick); + assert_ne!(h.app.session.active(), Some(first), "advanced"); +} + +/// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc +/// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star). +#[test] +fn slider_values_can_be_typed() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + let active = h.app.session.active().unwrap(); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating; + let (undo0, rating0) = (h.app.session.undo.len(), rating(&h)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); + h.request("ui.text", json!({"text": "1,5"}), t); + h.request("ui.key", json!({"key": "Enter"}), t); + assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); + assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); + assert_eq!(rating(&h), rating0, "typing 1 set no rating"); + // Esc keeps the value + assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); + h.request("ui.text", json!({"text": "-2"}), t); + h.request("ui.key", json!({"key": "Escape"}), t); + assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); + // something that isn't a number changes nothing + assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); + h.request("ui.text", json!({"text": "bright"}), t); + h.request("ui.key", json!({"key": "Enter"}), t); + assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); + assert_eq!(h.app.session.undo.len(), undo0 + 1); +} + +/// Reset takes precedence over the value being typed when its button is clicked. +#[test] +fn reset_button_wins_over_an_active_numeric_draft() { + let mut h = demo([960.0, 600.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{method}: {r}"); + r["result"].clone() + }; + ask(&mut h, "engine.execute", json!({"command": "app.gpu", "params": {"enabled": false}})); + ask(&mut h, "ui.set", json!({"settings": {"gpu": false, "previewEdge": 1280}, "presets": false})); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [1], "active": 1}})); + ask( + &mut h, + "ui.set", + json!({"view": "detail", "right": "edit", "leftPanel": false, "rightWidth": 330, "openSections": ["light"], "histogram": false}), + ); + let active = h.app.session.active().unwrap(); + for (text, terminal) in [("0.75", "Enter"), ("-1,5", "Escape")] { + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": text})); + ask(&mut h, "ui.key", json!({"key": terminal})); + let value = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!(value["light"]["exposure"].as_f64(), Some(0.75), "{terminal}: {value}"); + } + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": "2.0"})); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(crate::text_field::id("sliderField:light.exposure"))); + assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderField:light.exposure")); + assert!(h.painted_text().iter().any(|text| text == "2.0"), "the replacement draft must be visible before Reset"); + assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.75, "the draft is not committed yet"); + ask(&mut h, "ui.clickWidget", json!({"id": "button:reset"})); + let reset = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!( + reset["light"]["exposure"].as_f64(), + Some(0.0), + "Reset must win over the active numeric draft: {reset}; history={:?}; last_inverse={:?}; focus={:?}", + h.app.session.undo.iter().map(|entry| entry.label.as_str()).collect::>(), + h.app.session.undo.last().map(|entry| &entry.op), + h.view.ctx.memory(|m| m.focused()), + ); +} + +/// A slider's typed value is a shared text field: right-click ▸ Paste replaces the value +/// (selected when the field opens) and Return applies it; the menu doesn't close the field. +#[test] +fn a_slider_value_pastes_from_its_menu() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + h.view.clipboard = "0.75".into(); + for (id, button) in + [("sliderValue:light.exposure", "left"), ("sliderField:light.exposure", "right"), ("sliderField:light.exposure:paste", "left")] + { + let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); + } + h.request("ui.key", json!({"key": "Enter"}), t); + h.settle(SETTLE); + let exposure = h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert!((exposure - 0.75).abs() < 1e-9, "{exposure}"); +} + +/// The eye on a section header switches the section off and on again, one undo step each +/// (issue #316). +#[test] +fn section_eye_switches_a_section_off() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + let light_on = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().section_enabled("light"); + for expected in [false, true] { + // the eye shows while the pointer is on the header + assert_eq!(h.request("ui.hoverWidget", json!({"id": "section:light"}), t)["ok"], true); + assert_eq!(h.request("ui.clickWidget", json!({"id": "sectionEye:light"}), t)["ok"], true); + assert_eq!(light_on(&h), expected); + } + assert!(h.app.ui.section_open("light"), "the click didn't fold the section"); +} + +/// Return commits a tool panel back to Edit; elsewhere it does nothing. +#[test] +fn return_commits_the_crop_tool() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.crop"}), t); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop); + h.request("ui.key", json!({"key": "Enter"}), t); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit); + h.request("ui.key", json!({"key": "Enter"}), t); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools"); +} + +/// ⌘Q (File → Quit LightCraft) closes the window. +#[test] +fn cmd_q_quits() { + let mut h = demo([900.0, 600.0]); + assert!(!h.quit_requested()); + h.request("ui.key", json!({"key": "Q", "cmd": true}), Duration::from_secs(10)); + h.step(); + h.step(); + assert!(h.quit_requested()); +} + +/// File → Import from Folder… opens the import review for a folder (searched recursively). +#[test] +fn add_folder_opens_the_import_review() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-addfolder-{}", std::process::id())); + let sub = dir.join("day 2"); + std::fs::create_dir_all(&sub).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let png = lightcraft_engine::export::encode_image( + &img, + &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, + ) + .unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + std::fs::write(sub.join("b.png"), &png).unwrap(); + let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; + assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too"); + assert!(!opts.copy, "a folder is added in place by default"); + // the review names its source; scanning doesn't save a Local location + assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]); + let name = dir.file_name().unwrap().to_string_lossy().to_string(); + assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)")); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importSource"), "source shown"); + assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved"); + // The source facts were cached by a worker with the source-language default. They must + // still be rendered in German after switching, with the folder name kept verbatim. + assert_eq!(h.request("engine.execute", json!({"command": "app.language.german"}), t)["ok"], true); + h.settle(SETTLE); + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()), + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)), + _ => {} + } + } + let mut painted = Vec::new(); + for shape in &h.view.shapes { + texts(&shape.shape, &mut painted); + } + assert!(painted.iter().any(|text| text == &format!("Ordner „{name}“ (und seine Unterordner)")), "{painted:?}"); + for label in ["Quelle", "Übertragen", "Stichwörter", "Vorgabe"] { + assert!(painted.iter().any(|text| text == label), "{label}: {painted:?}"); + } + assert_eq!(h.request("engine.execute", json!({"command": "app.language.english"}), t)["ok"], true); + // a camera / card folder: copied into the library by default + h.app.ui.dialog = None; + let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert!(opts.copy); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it +/// narrower or taller, though the tabs hold different amounts: the window is one size on every tab. +#[test] +fn settings_tabs_keep_the_window_in_place() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let window = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == "dialog:window").map(|(_, r)| *r).expect("the dialog"); + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let first = window(&h); + let mut heights = Vec::new(); + for tab in ["display", "import", "performance", "general", "interface", "display"] { + h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + h.settle(SETTLE); + let now = window(&h); + assert!((now.top() - first.top()).abs() < 1.0, "{tab}: top {} → {}", first.top(), now.top()); + assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); + heights.push(now.height()); + } + assert!(heights.iter().all(|h| (h - first.height()).abs() < 1.0), "the tabs keep the height of {}: {heights:?}", first.height()); +} + +/// Settings → Import → "Show the import review when a camera or card is connected": a card that +/// was already in doesn't ask, a new one opens Import from Device (copying), and a busy window +/// asks once it's free. +#[test] +fn a_connected_card_opens_the_import_review() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let root = std::env::temp_dir().join(format!("lc-card-{}", std::process::id())); + let dcim = root.join("DCIM/100NIKON"); + std::fs::create_dir_all(&dcim).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dcim.join("DSC_0001.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let card = lightcraft_engine::devices::Device { + name: "NIKON Z 8".into(), + path: root.join("DCIM").to_string_lossy().to_string(), + root: root.to_string_lossy().to_string(), + }; + h.app.ui.dialog = None; + // first look: the card was already in + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none() && h.app.ui.dialog.is_none(), "a card that was already in doesn't ask"); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none()); + // taken out, then put back while another dialog is open: it waits + crate::import::cards_seen(&mut h.app, Vec::new()); + h.app.ui.dialog = Some(crate::state::Dialog::About); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none(), "a busy window isn't interrupted"); + h.app.ui.dialog = None; + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_some(), "the card is scanned once the window is free"); + assert!(h.step_until(t, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert!(opts.copy, "a card is copied into the library"); + // with the option off nothing is remembered, so turning it on again starts with a first look + assert!(!h.app.session.import_defaults.show_on_card); + h.step(); + assert!(h.app.ui.known_cards.is_none()); + let _ = std::fs::remove_dir_all(&root); +} + +/// The folder scan runs in the background: the request returns at once and the review opens +/// when the scan finishes (a folder on a network share must not freeze the window). +/// Clicking the folder being read again keeps the running read (and its progress). +#[test] +fn browsing_the_same_folder_again_does_not_restart() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-again-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (g, n) = (gate.clone(), started.clone()); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + n.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + let browse = |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(browse(&mut h)["result"]["scanning"], true); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(browse(&mut h)["result"]["scanning"], true); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(started.load(std::sync::atomic::Ordering::Relaxed), 1, "the second click must not start another read"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Browsing a folder from the UI (read on a worker thread) records it as browsed, as `library.browse` does: +/// untouched Local photos are only forgotten from folders not browsed for the set number of days. +#[test] +fn browsing_from_the_ui_records_the_folder() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-stamp-{}", std::process::id())); + let day = dir.join("2026/10/14"); + std::fs::create_dir_all(&day).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(day.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let root = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let day_s = day.to_string_lossy().to_string(); + assert!(h.app.session.catalog.last_browsed(&root).is_none()); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": root, "subfolders": true}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 1); + assert!(h.app.session.catalog.last_browsed(&root).is_some(), "the browsed folder"); + assert!(h.app.session.catalog.last_browsed(&day_s).is_some(), "the folder its photo is in"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by +/// another app, as `library.browse` does (issue #1392). +#[test] +fn browsing_again_from_the_ui_forgets_deleted_files() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-gone-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let png = lightcraft_engine::export::encode_image(&img, &o).unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + std::fs::write(dir.join("b.png"), &png).unwrap(); + let path = dir.to_string_lossy().to_string(); + let browse = |h: &mut Headless| { + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + }; + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 2); + // another app deletes the photo that isn't selected (a selected or open one stays, shown as missing) + let held: Vec = + h.app.session.selection.ids.iter().copied().chain(h.app.session.selection.active).chain(h.app.session.previous_active).collect(); + let name = |id: &lightcraft_catalog::PhotoId| h.app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default(); + let gone = if held.iter().any(|id| name(id) == "b.png") { "a.png" } else { "b.png" }; + std::fs::remove_file(dir.join(gone)).unwrap(); + // a later session: the browse's "Add 2 Photos" step is no longer in the undo history (photos it refers to + // are kept, like selected ones) + h.app.session.undo.clear(); + h.app.session.redo.clear(); + // the folder clicked again in the sidebar + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 1, "the deleted file's record is forgotten"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn add_folder_scans_in_the_background() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-scanbg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let png = lightcraft_engine::export::encode_image( + &img, + &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, + ) + .unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert!(h.app.scan.is_some() || h.app.ui.dialog.is_some()); + h.settle(SETTLE); + assert!(h.app.scan.is_none()); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Cancel closes the scan at once and opens no review; Add from Device while a scan runs is +/// refused and leaves the running scan's options alone. +#[test] +fn folder_scan_cancel_and_busy() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-scancancel-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let png = lightcraft_engine::export::encode_image( + &img, + &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, + ) + .unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + // a scan that can't finish until the test lets it: the probe waits on a flag + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": dir.to_string_lossy()}}), t); + assert_ne!(r["ok"], true, "a second scan is refused: {r}"); + assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options"); + let r = h.request("ui.inspect", json!({}), t); + assert!(r["result"]["scan"].is_object(), "{r}"); + // Cancel is the ✕ of the scan's row in the activity stack (shown once it is half a second old) + let id = h.app.session.activity.list().first().map(|t| t.id).expect("a scan row"); + let cross = format!("activity:cancel:{id}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + let r = h.request("ui.clickWidget", json!({"id": cross}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.scan.is_none(), "✕ closes the scan at once"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group +/// leaves it alone. ⌘F puts typing into the search field. +#[test] +fn paste_selected_settings_and_find() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}})); + exec(&mut h, "develop.copy", json!({"groups": ["light", "color"]})); + exec(&mut h, "library.select", json!({"ids": [ids[1]]})); + h.request("ui.key", json!({"key": "V", "cmd": true, "shift": true}), t); + h.settle(SETTLE); + let Some(crate::state::Dialog::PasteSettings { groups }) = &h.app.ui.dialog else { panic!("dialog not open: {:?}", h.app.ui.dialog) }; + assert_eq!(groups, &["light", "color"]); + let r = h.request("ui.clickWidget", json!({"id": "pasteGroup:color"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.dialog.confirm", json!({}), t)["ok"], true); + let d = h.app.session.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!((d.light.exposure, d.color.vibrance), (1.0, 0.0)); + // ⌘F, then typing filters the grid by text + h.request("ui.set", json!({"view": "grid"}), t); + h.request("ui.key", json!({"key": "F", "cmd": true}), t); + h.settle(SETTLE); + h.request("ui.text", json!({"text": "zz-no-such-photo"}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.search, "zz-no-such-photo"); + assert!(h.app.session.visible_cloned().is_empty()); +} + +/// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos. +#[test] +fn capture_time_dialog_shifts_time_zone() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); + // The dock header leaves less vertical space; bring the metadata action into view. + let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; + assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}"); + h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t); + h.request("ui.scroll", json!({"dy": -160.0}), t); + h.settle(SETTLE); + let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; + assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}"); + let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog { + *zone = -3.0; + } else { + panic!("dialog not open: {:?}", h.app.ui.dialog); + } + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let after = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); + let secs = lightcraft_catalog::dates::iso_seconds; + assert_eq!(secs(&after).unwrap() - secs(&before).unwrap(), -3 * 3600); + h.settle(SETTLE); +} + +/// Colour labels: Info-panel swatches set/clear the label, the names dialog renames them and +/// the Photo menu shows the names. +#[test] +fn label_swatches_and_names() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + let r = h.request("ui.clickWidget", json!({"id": "label:green"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.catalog.photo(id).unwrap().label, Some(lightcraft_catalog::ColorLabel::Green)); + h.request("engine.execute", json!({"command": "dialog.labelNames"}), t); + if let Some(crate::state::Dialog::LabelNames { names, .. }) = &mut h.app.ui.dialog { + names[2] = "Approved".into(); + } else { + panic!("no dialog"); + } + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let tree = h.request("ui.menu.tree", json!({}), t).to_string(); + assert!(tree.contains("Approved (Green)"), "menu shows the name"); + h.request("ui.clickWidget", json!({"id": "label:green"}), t); + assert_eq!(h.app.session.catalog.photo(id).unwrap().label, None, "clicking the current label clears it"); + h.settle(SETTLE); +} + +/// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is +/// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album +/// with keywords, as one undo step. +#[test] +fn import_review_dialog() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..5u8 { + let (w, h) = (40usize, 30usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 40, (k / w * 8) as u8, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + std::fs::copy(dir.join("img0.png"), dir.join("img0-copy.png")).unwrap(); + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let n0 = h.app.session.catalog.len(); + let undo0 = h.app.session.undo.len(); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + h.step_until(SETTLE, |h| h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 6); + assert_eq!(opts.candidates.iter().filter(|c| c.duplicate.is_some()).count(), 1); + assert!(h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5, "thumbnails"); + // uncheck the first photo; name a new album and keywords + let r = h.request("ui.clickWidget", json!({"id": "import:0"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + assert_eq!(opts.selected_paths().len(), 4, "duplicate and unchecked photo left out"); + opts.new_album = "Card 1".into(); + opts.keywords = "cardone, 2026".into(); + } else { + panic!("no import dialog"); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + assert_eq!(h.app.session.catalog.len(), n0 + 4); + let album = h.app.session.catalog.albums().find(|a| a.name == "Card 1").expect("album").id; + assert_eq!(h.app.session.catalog.album_count(album), 4); + assert!(h.app.session.catalog.photos().filter(|p| p.meta.keywords.contains(&"cardone".to_string())).count() == 4); + assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + assert_eq!(h.app.session.catalog.len(), n0); + h.settle(SETTLE); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The import review offers what to do with files that are in Recently Deleted (issues #298, +/// #706): they are checked, to be restored, by default; Leave them unchecks them; confirming +/// restores the photo (with its edits) or, with Import as new, imports the file afresh. +#[test] +fn import_review_offers_recently_deleted_files() { + for (choice, tag) in [("restore", "r"), ("fresh", "f")] { + let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); + let id = r["result"]["imported"][0].as_u64().unwrap(); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.on_deleted, "restore", "the default brings a deleted photo back"); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked from the start: it will be restored"); + opts.set_on_deleted(""); + assert_eq!(opts.selected_paths().len(), 1, "Leave them: only the new file"); + opts.set_on_deleted(choice); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked again once a choice is made"); + // a per-cell choice survives switching between the two options, and "Leave them" unchecks + let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap(); + opts.checked[trashed] = false; + opts.set_on_deleted(if choice == "restore" { "fresh" } else { "restore" }); + assert_eq!(opts.selected_paths().len(), 1, "still unchecked"); + opts.set_on_deleted(""); + assert_eq!(opts.selected_paths().len(), 1); + opts.set_on_deleted(choice); + assert_eq!(opts.selected_paths().len(), 2); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + // frames until the import has finished, so its (timed) toast is read before it expires + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "finished"); + let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); + let photos = &h.app.session.catalog; + assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2, "{choice}"); + let old = photos.photo(lightcraft_catalog::PhotoId(id)); + if choice == "restore" { + assert!(toast.contains("1 restored"), "{toast}"); + assert!(old.is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); + assert_eq!(photos.len(), 2); + } else { + assert!(old.is_none(), "the trashed record is gone"); + assert_eq!(photos.len(), 2); + } + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// With "Leave them", a file in Recently Deleted is left unchecked but a click on its checkbox +/// (or on its "In Recently Deleted" badge) still checks it, and importing it restores the +/// photo (issue #1285). +#[test] +fn import_review_checks_a_recently_deleted_file_by_hand() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-click-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); + let id = r["result"]["imported"][0].as_u64().unwrap(); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; + let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).expect("a file in Recently Deleted"); + opts.set_on_deleted(""); + h.settle(SETTLE); + let checked = |h: &Headless| matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Import { opts }) if opts.checked[trashed]); + assert!(!checked(&h), "Leave them: unchecked"); + let cell = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({"filter": format!("import:{trashed}")}), t); + let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == format!("import:{trashed}"))).cloned().expect("cell on screen"); + [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) + }; + // the checkbox: 16 pt, 6 pt in from the cell's top-left corner + let [x, y, ..] = cell(&mut h); + h.request("ui.click", json!({"x": x + 14.0, "y": y + 14.0}), t); + h.settle(SETTLE); + assert!(checked(&h), "a click on the checkbox checks the file"); + // the badges ("Will be restored" at the top right, "In Recently Deleted" under the + // checkbox) don't swallow the click either + let [x, y, w, _] = cell(&mut h); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 12.0}), t); + h.settle(SETTLE); + assert!(!checked(&h), "a click on the badge unchecks it again"); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 34.0}), t); + h.settle(SETTLE); + assert!(checked(&h)); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "finished"); + let photos = &h.app.session.catalog; + assert!(photos.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); + assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2); + assert_eq!(photos.len(), 2, "restored, not imported a second time"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its +/// corner: the photo grid takes the new height, and the window then keeps its size (issue #337). +#[test] +fn import_review_dialog_resizes() { + let mut h = demo([1400.0, 1000.0]); + let t = Duration::from_secs(10); + let candidates = (0..60) + .map(|i| lightcraft_engine::import::ImportCandidate { + path: format!("/lc-test/img{i}.png"), + name: format!("img{i}.png"), + error: Some("not read in this test".into()), + ..Default::default() + }) + .collect(); + h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(candidates)) }); + let rect = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")).expect("dialog on screen"); + [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) + }; + for _ in 0..10 { + h.step(); + } + let r0 = rect(&mut h); + assert!(r0[3] > 600.0, "{r0:?}"); + // the Copy options add rows below the grid: the grid makes room, the window doesn't grow + for copy in [true, false] { + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.copy = copy; + } + for _ in 0..10 { + h.step(); + } + let r = rect(&mut h); + assert!((r[3] - r0[3]).abs() < 0.5, "copy {copy}: {r0:?} → {r:?}"); + } + let (x, y) = (r0[0] + r0[2] - 3.0, r0[1] + r0[3] - 3.0); + h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 160.0, "toY": y + 120.0, "steps": 12}), t); + for _ in 0..10 { + h.step(); + } + let r1 = rect(&mut h); + assert!(r1[2] > r0[2] + 100.0 && r1[3] > r0[3] + 80.0, "dragging the corner resized it: {r0:?} → {r1:?}"); + for _ in 0..60 { + h.step(); + } + let r2 = rect(&mut h); + assert!((r2[2] - r1[2]).abs() < 0.5 && (r2[3] - r1[3]).abs() < 0.5, "the dialog kept its size: {r1:?} → {r2:?}"); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open"); +} + +/// Adding a file again (dropped on the window, no review) whose photo is in Recently Deleted +/// brings the photo back, selected in All Photos, and says so (issue #706) — it is not +/// skipped as a duplicate; the trash view's menus offer Restore and Delete Permanently, and a +/// re-add of a photo that is in the library selects it in All Photos. +#[test] +fn readding_a_deleted_photo_shows_it_in_recently_deleted() { + let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let file = dir.join("flower.png"); + std::fs::write(&file, png).unwrap(); + let paths = vec![file.to_string_lossy().to_string()]; + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + // frames until the import has finished, so its (timed) toast is read before it expires + let finish_import = |h: &mut Headless| { + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "import finished"); + }; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + h.settle(SETTLE); + let id = h.app.session.selection.active.expect("imported photo selected"); + let n0 = h.app.session.catalog.len(); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted); + let photo_items = |app: &LightcraftApp| -> Vec { + let bar = crate::menubar::menu_bar(app); + let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1; + items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect() + }; + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); + + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + finish_import(&mut h); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "re-adding the file restores the photo"); + assert_eq!(h.app.session.catalog.len(), n0, "no second record"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id], "the restored photo is selected"); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("Restored 1 photo from Recently Deleted"), "{toast}"); + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string())); + // undo of the re-add puts it back in Recently Deleted; the trash view's menus offer Restore + h.request("engine.execute", json!({"command": "edit.undo"}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted, "one undo step"); + h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), t); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); + let items = photo_items(&h.app); + assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); + assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); + // a right-click on its filmstrip thumbnail opens the photo menu + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu"); + + // Empty Recently Deleted is offered while the trash is the source + assert!(photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string())); + let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored"); + crate::import::start_paths(&mut h.app, paths).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id]); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("All Photos"), "{toast}"); + assert!(!photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()), "Empty is for the trash view only"); + + // deleted permanently, the file imports afresh as a new photo + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).is_none()); + crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap(); + h.settle(SETTLE); + let fresh = h.app.session.selection.active.expect("re-imported photo selected"); + assert_ne!(fresh, id); + assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Importing while an album is shown (issue #1446): the new photos aren't in it, so the view +/// switches to All Photos with the first of them active, instead of an active photo the grid +/// and the filmstrip don't show. +#[test] +fn an_import_from_an_album_view_shows_the_first_new_photo() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-show-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..3u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 40, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("IMG_{i:02}.png")), png).unwrap(); + } + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let album = h.app.session.catalog.albums().find(|a| !a.folder && !a.is_smart()).map(|a| a.id.0).expect("a demo album"); + let r = h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "album", "id": album}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let before: Vec = h.app.session.catalog.photos().map(|p| p.id).collect(); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let active = h.app.session.selection.active.expect("a photo is active"); + assert!(!before.contains(&active), "the active photo is a new one: {active:?}"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert!(h.app.session.visible_cloned().contains(&active), "the grid shows the active photo"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across +/// the import's batches. +#[test] +fn import_copy_renames_into_destination() { + let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("out")); + std::fs::create_dir_all(&src).unwrap(); + // more files than a batch holds (see `batch_size`) + for i in 0..20u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap(); + } + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 20); + for id in ["button:importCopy", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + assert!(opts.copy); + assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder"); + opts.organize = "flat".into(); + opts.rename = "Trip-{seq:2}".into(); + } else { + panic!("no import dialog"); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let mut names: Vec = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + names.sort(); + let want: Vec = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect(); + assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size()); + let _ = std::fs::remove_dir_all(&base); +} + +/// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}` +/// files copies under `2026/20260114/`, the example line shows the full destination, and a +/// template that would climb out of the destination is refused. +#[test] +fn import_copy_into_a_custom_folder_template() { + let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join("IMG_01.png"), png).unwrap(); + // undated files are filed by the file's date (2026-01-14T05:58:48) + std::fs::File::options() + .write(true) + .open(src.join("IMG_01.png")) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let mut app = LightcraftApp::new(session, services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + for id in ["button:importCopy", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.organize = "custom".into(); + opts.folder_template = "../{date:%Y}".into(); + } + h.step(); + // refused: the dialog stays open, nothing is imported + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.import.is_none()); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; + opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); + let opts = opts.clone(); + let sep = std::path::MAIN_SEPARATOR; + let example = crate::import::example_destination(&h.app, &opts).expect("example"); + assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy())); + h.step(); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importExample"), "example shown"); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file()); + let _ = std::fs::remove_dir_all(&base); +} + +/// Import review, Move: an explicit choice with its explanation, the confirm button says +/// Move; the files land in the folder template, renamed, and leave the card. +#[test] +fn import_move_into_a_folder_template() { + let base = std::env::temp_dir().join(format!("lc-ui-import-move-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("Photos")); + std::fs::create_dir_all(&src).unwrap(); + for i in 0..2u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); + std::fs::File::options() + .write(true) + .open(src.join(format!("IMG_0{i}.png"))) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); + } + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let mut app = LightcraftApp::new(session, services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + for id in ["button:importMove", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import dialog") }; + assert!(opts.copy && opts.move_files, "Move chosen"); + opts.organize = "custom".into(); + opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); + opts.rename = "{date:%Y%m%d}_{seq:3}".into(); + let opts = opts.clone(); + let sep = std::path::MAIN_SEPARATOR; + let example = crate::import::example_destination(&h.app, &opts).expect("example"); + assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}20260114_001.png", dest.to_string_lossy())); + h.step(); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importModeHelp"), "the Move explanation is shown"); + assert!(!r.to_string().contains("check:importDng"), "no Copy as DNG when moving"); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let day = dest.join("2026").join("20260114"); + assert!(day.join("20260114_001.png").is_file() && day.join("20260114_002.png").is_file()); + assert_eq!(std::fs::read_dir(&src).unwrap().count(), 0, "moved off the card"); + let _ = std::fs::remove_dir_all(&base); +} + +/// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one +/// inserts it at the text cursor (not appended, not replacing the template). +#[test] +fn import_rename_tags_insert_at_the_cursor() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join("IMG_0007.png"), png).unwrap(); + let lib = dir.join("lib"); + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.open_library(&lib, false).unwrap(); + let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.rename = "Trip-_x".into(); + } + // Paint the edited template before locating the Tags button in the resized dialog. + assert!(h.settle(SETTLE), "Copy dialog layout should settle before locating Tags"); + // the cursor sits after "Trip-" + let id = egui::Id::unique("import-rename"); + let mut st = egui::text_edit::TextEditState::default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5)))); + st.store(&h.view.ctx, id); + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t); + assert_eq!(r["ok"], true, "{r}"); + let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap(); + let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor"); + // a second tag goes after the first (the cursor moved past it) + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}{name}_x"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Help ▸ About: the About, Contributors and Models tabs switch and paint (credits are +/// compiled in). +#[test] +fn about_dialog_tabs_show_the_credits() { + // an empty library: the dialog needs no photos, and no decodes compete with other tests + let services = crate::Services { png: None, ..Default::default() }; + let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::new(), services), [1300.0, 820.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("ui.menu.invoke", json!({"id": "app.about"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About)); + // a new window sizes itself on its first frame: let it settle before clicking its tabs + h.step(); + h.step(); + for (i, (tab, _)) in crate::panels::dialogs::ABOUT_TABS.iter().enumerate().rev() { + let r = h.request("ui.clickWidget", json!({"id": format!("button:aboutTab-{tab}")}), t); + assert_eq!(r["ok"], true, "{tab}: {r}"); + h.step(); + let shown = h.view.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("about_tab"))); + assert_eq!(shown.map(usize::from), Some(i), "{tab}"); + } + h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1300, 820]); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About), "switching tabs keeps the dialog open"); +} + +/// Settings ▸ Display / `app.displayProfile`: previews are shown through the monitor profile +/// (the loupe rendered for the display, thumbnails converted from sRGB, the histogram still +/// sRGB); a file that can't be used is an error, or, as the setting, reported with previews +/// left sRGB. +#[test] +fn display_profile_converts_previews() { + use crate::render::Slot; + let dir = std::env::temp_dir().join(format!("lightcraft-display-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p3 = dir.join("p3.icc"); + std::fs::write(&p3, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap(); + let bad = dir.join("bad.icc"); + std::fs::write(&bad, b"not a profile").unwrap(); + let (p3, bad) = (p3.to_string_lossy().to_string(), bad.to_string_lossy().to_string()); + + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + let thumb_slot = |h: &Headless| h.app.renderer.textures.keys().copied().filter(|s| matches!(s, Slot::Thumb(_))).min_by_key(|s| format!("{s:?}")); + let ready = |h: &Headless| { + h.app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.pixels.is_some()) + && thumb_slot(h).is_some() + && !h.app.renderer.is_pending(Slot::Main) + }; + assert!(h.step_until(SETTLE, ready)); + h.settle(SETTLE); + let rgba = |c: &egui::ColorImage| lightcraft_raster::Rgba8 { + width: c.size[0], + height: c.size[1], + data: c.pixels.iter().map(|p| p.to_array()).collect(), + }; + let tex = |h: &Headless, s: Slot| rgba(h.app.renderer.textures.get(&s).and_then(|t| t.pixels.clone()).as_deref().unwrap()); + let thumb = thumb_slot(&h).unwrap(); + let (main0, thumb0) = (tex(&h, Slot::Main), tex(&h, thumb)); + let hist0 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); + + // the command loads it (and keeps it as the setting); textures are made again + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": p3}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["kind"], "matrix"); + assert_eq!(h.app.ui.settings.display_profile, p3); + let d = h.app.renderer.display().cloned().unwrap(); + assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); + h.settle(SETTLE); + let (main1, thumb1) = (tex(&h, Slot::Main), tex(&h, thumb)); + let mut want = thumb0.clone(); + d.profile.from_srgb(&mut want).unwrap(); + let max_diff = |a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8| { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] as i32 - q[k] as i32).abs())).max().unwrap_or(0) + }; + assert_eq!((thumb1.width, thumb1.height), (thumb0.width, thumb0.height)); + assert!(max_diff(&thumb1, &want) <= 1, "thumbnails are converted from sRGB: {}", max_diff(&thumb1, &want)); + assert_ne!(thumb1, thumb0); + // the loupe: rendered for the display; colours sRGB holds look as they did + assert_eq!((main1.width, main1.height), (main0.width, main0.height)); + assert_ne!(main1, main0); + let mut main_want = main0.clone(); + d.profile.from_srgb(&mut main_want).unwrap(); + let mean = main1.data.iter().zip(&main_want.data).map(|(p, q)| (0..3).map(|k| (p[k] as f64 - q[k] as f64).abs()).sum::()).sum::() + / (3 * main1.data.len()) as f64; + assert!(mean < 1.5, "{mean}"); + let hist1 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); + assert_eq!(hist1.total, hist0.total, "the histogram stays sRGB"); + + // a bad file: an error, nothing changes + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": bad}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.renderer.display_id(), Some(d.id())); + assert_eq!(h.app.ui.settings.display_profile, p3); + // …chosen as the setting (Settings ▸ Display), it is reported and previews go back to sRGB + h.app.ui.settings.display_profile = bad.clone(); + h.step(); + assert!(h.app.display_error.as_deref().is_some_and(|e| e.contains("not a valid ICC profile")), "{:?}", h.app.display_error); + assert_eq!(h.app.renderer.display_id(), None); + // the Display tab shows it + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "display"}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + // none: sRGB again + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(r["result"]["path"].is_null()); + assert!(h.app.ui.settings.display_profile.is_empty() && h.app.display_error.is_none()); + assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); + h.settle(SETTLE); + assert_eq!(tex(&h, thumb), thumb0, "back to the sRGB thumbnail"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With no profile chosen, previews follow the profile of the screen showing the window (the +/// host's `screen_profile`: macOS doesn't colour-manage the window, issue #787), read again so +/// a move to another screen is followed. A chosen profile wins; choosing none goes back to the +/// screen's at once; a screen without a profile is sRGB. +#[test] +fn previews_follow_the_screen_profile_while_none_is_chosen() { + use lightcraft_codecs::{NamedSpace, icc::write_named}; + use std::sync::{Arc, Mutex}; + let id = |space| lightcraft_engine::display::Display::from_bytes("", &write_named(space)).unwrap().id(); + let dir = std::env::temp_dir().join(format!("lightcraft-screen-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let chosen = dir.join("prophoto.icc"); + std::fs::write(&chosen, write_named(NamedSpace::ProPhoto)).unwrap(); + let chosen = chosen.to_string_lossy().to_string(); + + let screen = Arc::new(Mutex::new(Some(write_named(NamedSpace::DisplayP3)))); + let mut h = demo([1000.0, 700.0]); + let read = screen.clone(); + h.app.services.screen_profile = Some(Box::new(move || read.lock().unwrap().clone())); + let t = Duration::from_secs(10); + h.step(); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3)), "the screen's profile"); + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {}}), t); + assert_eq!((r["result"]["screen"].clone(), r["result"]["path"].clone()), (json!(true), Value::Null), "{r}"); + assert!(h.app.ui.settings.display_profile.is_empty(), "nothing is saved as the setting"); + + // the window moves to another screen + *screen.lock().unwrap() = Some(write_named(NamedSpace::AdobeRgb)); + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id() == Some(id(NamedSpace::AdobeRgb)))); + + // a chosen profile wins over the screen's + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": chosen}}), t); + assert_eq!(r["ok"], true, "{r}"); + *screen.lock().unwrap() = Some(write_named(NamedSpace::DisplayP3)); + for _ in 0..90 { + h.step(); + } + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::ProPhoto))); + + // none chosen again: the screen's profile, at once + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); + assert_eq!(r["result"]["screen"], true, "{r}"); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3))); + + // a screen without a profile: sRGB + *screen.lock().unwrap() = None; + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id().is_none())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through +/// the engine; the delete confirmation guards ⌫. +#[test] +fn settings_dialog_by_keyboard_and_clicks() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() })); + for tab in ["import", "performance", "display", "interface", "general"] { + let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + assert_eq!(r["ok"], true, "{tab}: {r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); + } + // General: confirm before delete (on by default), startup view + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(!h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); + assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); + // Interface: filmstrip names off, grid badges always + h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + // Appearance cards (one row per theme, with its favorite star) sit above these controls; + // scroll the compact settings viewport past them. + h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); + h.request("ui.scroll", json!({"dy": -700.0}), t); + for _ in 0..4 { + h.step(); + } + h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); + assert!(!h.app.ui.settings.film_names); + h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); + assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + h.request("ui.scroll", json!({"dy": 700.0}), t); + for _ in 0..4 { + h.step(); + } + // Performance: the thumbnail cache size goes to the library preferences + h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); + h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); + assert_eq!(h.app.session.cache_mb, 512); + h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t); + assert_eq!(h.app.ui.settings.preview_limit, 3840); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); + assert_eq!(h.app.ui.settings.memory_mb, 1024); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget"); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic"); + // Import: per-camera defaults + h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); + h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); + assert!(h.app.session.import_defaults.per_camera); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1300, 820]); + // Escape closes; ⌫ now asks first + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.dialog, None); + let active = h.app.session.active().unwrap(); + h.request("ui.key", json!({"key": "delete"}), t); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); + assert!(!h.app.session.catalog.photo(active).unwrap().deleted); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(active).unwrap().deleted); + // app settings survive a save/load round trip of the UI state (ui.json) + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap().sanitized(); + assert_eq!(back.settings, h.app.ui.settings); + assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load"); + h.settle(SETTLE); +} + +/// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the +/// overlay in Detail; ⇧⌘F asks the host for window full screen. +#[test] +fn full_screen_preview_and_info_overlay_by_keyboard() { + use crate::state::InfoOverlay; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t); + h.request("ui.key", json!({"key": "f"}), t); + assert!(h.app.ui.fullscreen); + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1])); + let right = h.app.ui.right; + h.request("ui.key", json!({"key": "i"}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic); + assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen"); + let img = h.snapshot(SETTLE); + // no chrome: the frame's corners are black + for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] { + let c = img.pixels[y * 1000 + x]; + assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}"); + } + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay")); + h.request("ui.key", json!({"key": "escape"}), t); + assert!(!h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered"); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(h.app.window_is_fullscreen); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(!h.app.window_is_fullscreen); + h.settle(SETTLE); +} + +/// #911 (toast labels only): cycling the overlay announces each mode in the UI language. +#[test] +fn info_overlay_toasts_are_painted_in_the_users_language() { + use crate::i18n::Locale; + + struct RestoreLanguage(Locale); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_language(self.0); + } + } + let _restore = RestoreLanguage(crate::i18n::language()); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); + for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { + assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); + assert_eq!(h.app.ui.language, locale); + assert_eq!(crate::i18n::language(), locale); + for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { + let expected = locale.tr(key); + assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); + assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); + assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); + h.step(); + let painted = h.painted_text(); + assert!(painted.iter().any(|text| text == expected), "{painted:?}"); + } + } +} + +/// ⌘J opens View ▸ View Options; its menus choose Info 1's and Info 2's lines, Use Defaults puts +/// them back, and `view.viewOptions` sets them for an agent (refusing a line it doesn't know). +#[test] +fn view_options_choose_the_info_overlay_lines() { + use crate::state::{LoupeInfo, LoupeInfoField as F}; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ViewOptions)); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "combo:viewOptionsInfo1Line2"}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsInfo1Line2-lens"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, [F::FileName, F::Lens, F::Dimensions]); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsDefaults1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, LoupeInfo::INFO1); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["camera", "none", "fileName"]}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName]); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["rating", "none", "none"]}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing"); +} + +/// View Options' Show Briefly When Photo Changes: a tick box under each Info (ticking one +/// unticks the other) and `view.viewOptions {briefly}`; with the overlay off, the loupe shows +/// the chosen Info after → changes the photo, and hides it again a few seconds later. +#[test] +fn show_briefly_when_photo_changes_in_view_options() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly2"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Exposure); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Basic, "one Info at most"); + h.app.ui.dialog = None; + h.step(); + + let overlay = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "hidden once the time has passed"); + let before = h.app.session.active(); + h.request("ui.key", json!({"key": "right"}), t); + assert_ne!(h.app.session.active(), before); + h.step(); + assert!(overlay(&h), "shown after → changes the photo"); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "and hidden again"); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "off"}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["loupeInfo"]["briefly"], "off", "{r}"); + h.request("ui.key", json!({"key": "right"}), t); + h.step(); + assert!(!overlay(&h), "off: nothing shows when the photo changes"); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "info3"}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Off, "a refused change changes nothing"); +} + +/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the +/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. +#[test] +fn click_zoom_ratio_animates() { + use crate::render::Slot; + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default"); + let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t); + assert_eq!(r["result"]["ratio"], 3, "{r}"); + let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t); + assert_ne!(r["ok"], true, "{r}"); + let fit = h.app.image_rect.unwrap(); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); + assert!(h.app.ui.zoom_anim, "animation started"); + // every animation frame asks for the same (final-size) render + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.extend(h.app.renderer.wanted(Slot::Main)); + if !h.app.ui.zoom_anim { + break; + } + } + assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}"); + h.settle(SETTLE); + assert!(!h.app.ui.zoom_anim, "animation finished"); + assert!(h.app.image_rect.unwrap().width() > fit.width()); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + // Z zooms to the same ratio as a click + h.request("ui.key", json!({"key": "z"}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); + h.request("ui.key", json!({"key": "z"}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + h.settle(SETTLE); +} + +/// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the +/// loupe render is, not a preview-level render the GPU shrinks. +#[test] +fn a_hover_preview_is_fitted_to_the_drawn_size() { + use crate::render::Slot; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); + let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); + assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); +} + +/// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of +/// rising to the preview level. +#[test] +fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { + use crate::render::Slot; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); +} + +#[test] +fn trackpad_navigation_keeps_the_image_cursor_between_events() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), t); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + let anchor = h.app.canvas_rect.unwrap().center(); + h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); + for (tool, expected) in [ + ("", egui::CursorIcon::Grab), + ("wbPicker", egui::CursorIcon::Crosshair), + ("colorRange", egui::CursorIcon::Crosshair), + ("pointColor", egui::CursorIcon::Crosshair), + ("tat:curve", egui::CursorIcon::ResizeVertical), + ] { + h.app.ui.tool = tool.into(); + for event in [ + None, + Some(egui::Event::Zoom(1.01)), + None, + Some(egui::Event::Zoom(1.01)), + Some(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(10.0, -10.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers: egui::Modifiers::NONE, + }), + None, + ] { + let raw = HeadlessView::raw_input(h.size, h.pixels_per_point, h.time, event.into_iter().collect()); + let mut cursor = egui::CursorIcon::Default; + h.view.run(raw, |ui| { + h.app.logic(ui.ctx()); + h.app.ui(ui); + cursor = ui.ctx().output(|output| output.cursor_icon); + }); + h.time += FRAME_DT; + assert_eq!(cursor, expected, "cursor changed between navigation events with tool {tool:?}"); + } + } +} + +#[test] +fn trackpad_pinch_anchors_fractional_zoom_and_scroll_pans() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + h.pixels_per_point = 2.0; + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + let before = h.app.image_rect.unwrap(); + let anchor = h.app.canvas_rect.unwrap().center() + egui::vec2(30.0, -20.0); + h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); + let point = (anchor - before.min) / before.size(); + h.request("ui.zoom", json!({"factor": 1.001}), t); + let after = h.app.image_rect.unwrap(); + assert!((after.width() / before.width() - 1.001).abs() < 0.00001); + assert!(((anchor - after.min) / after.size() - point).length() < 0.00001, "point under pointer moved"); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if (p - 100.1).abs() < 0.001), "{:?}", h.app.ui.zoom); + assert!(!h.app.ui.zoom_anim, "pinch follows the fingers immediately"); + + h.request("ui.scroll", json!({"dx": 40.0, "dy": -30.0}), t); + for _ in 0..40 { + h.step(); + } + let panned = h.app.image_rect.unwrap(); + assert!(panned.left() > after.left() && panned.top() < after.top(), "two-finger pan must move both axes"); + h.request("ui.scroll", json!({"dx": 100000.0, "dy": -100000.0}), t); + for _ in 0..40 { + h.step(); + } + let edge = h.app.image_rect.unwrap(); + let area = h.app.canvas_rect.unwrap().shrink(24.0); + assert!((edge.left() - area.left()).abs() < 0.01 && (edge.bottom() - area.bottom()).abs() < 0.01, "{edge:?} vs {area:?}"); + + h.request("ui.zoom", json!({"factor": 1e20}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(800.0)); + h.request("ui.zoom", json!({"factor": 0.000001}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); + // Panel / toolbar gestures must not move the image. + h.request("ui.move", json!({"x": 10, "y": 10}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + h.request("ui.scroll", json!({"dx": 100, "dy": 100}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); +} + +#[test] +fn mouse_wheel_zooms_the_loupe_with_and_without_ctrl() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); + h.settle(SETTLE); + for (unit, y, modifiers) in [ + (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE), + (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL), + (egui::MouseWheelUnit::Point, 50.0, egui::Modifiers::CTRL), + ] { + h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.app.synthetic.push(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, y), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers, + }); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "wheel {unit:?} {modifiers:?} must zoom in, got {:?}", h.app.ui.zoom); + } +} + +#[test] +fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "leftPanel": false, "filmstrip": false}), t); + h.settle(SETTLE); + let active = h.app.session.active().unwrap(); + let settings = h.app.session.catalog.photo(active).unwrap().develop.clone(); + for right in ["crop", "masking", "remove", "redEye", "none"] { + h.request("ui.set", json!({"right": right, "zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch in {right}"); + assert_eq!(h.app.session.catalog.photo(active).unwrap().develop, settings, "navigation must not edit the photo"); + } + h.request("ui.set", json!({"fullscreen": true, "zoom": "fit"}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "full-screen pinch"); + + h.request("ui.set", json!({"fullscreen": false, "zoom": "fit", "beforeAfter": "sideBySide"}), t); + let canvas = h.app.canvas_rect.unwrap(); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch over the Before pane"); + + h.request("ui.set", json!({"beforeAfter": "off", "zoom": "fit"}), t); + h.request("engine.execute", json!({"command": "view.compare"}), t); + // The left Compare pane must navigate too; zoom/pan are shared with the right pane. + let canvas = h.app.canvas_rect.unwrap(); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Compare pinch"); + let pan = h.app.ui.pan; + h.request("ui.scroll", json!({"dx": -50, "dy": -50}), t); + assert_ne!(h.app.ui.pan, pan, "Compare pan"); + + h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("engine.execute", json!({"command": "view.reference"}), t); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Reference pinch"); +} + +#[test] +fn navigation_rejects_invalid_input_and_preserves_old_zoom_state() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(serde_json::from_value::(json!({"percent": 100})).unwrap(), Zoom::Percent(100.0)); + for factor in [json!(0), json!(-1), json!(1e308), json!("big"), Value::Null] { + let r = h.request("ui.zoom", json!({"factor": factor}), t); + assert_eq!(r["ok"], false, "{r}"); + } + for params in [ + json!({"zoom": {"percent": -1}}), + json!({"zoom": {"percent": 1e308}}), + json!({"zoom": "oops"}), + json!({"zoom": {"percent": 123.4}, "pan": [-1, 0.5]}), + json!({"pan": [0.5, 1e308]}), + json!({"pan": [0.5]}), + ] { + assert!(h.app.run("view.navigate", params).is_err()); + assert_eq!(h.app.ui.zoom, Zoom::Fit, "failed request changed zoom"); + assert_eq!(h.app.ui.pan, (0.5, 0.5), "failed request changed pan"); + } + h.app.run("view.navigate", json!({"zoom": {"percent": 123.4}, "pan": [0.4, 0.6]})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(123.4)); + assert_eq!(h.app.ui.pan, (0.4, 0.6)); + h.app.run("view.zoomIn", json!({})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(200.0)); + h.app.run("view.zoomOut", json!({})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(100.0)); + + let area = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(1000.0, 700.0)); + for pan in [(0.0, 0.0), (1.0, 1.0)] { + let image = crate::panels::detail::fit_rect(area, 10.0, Zoom::Percent(100.0), [4000, 400], 1.0, pan); + assert_eq!(image.center().y, area.center().y, "smaller axis must stay centred"); + assert!(image.left() <= area.left() && image.right() >= area.right(), "pan exposed space outside the photo"); + } +} + +/// The Navigator appears when zoomed in; clicking it pans to that point. +#[test] +fn navigator_pans_the_zoomed_loupe() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit"); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed"); + h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0), "the click didn't reach the loupe (which would zoom out)"); + h.settle(SETTLE); + let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap(); + let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7); + h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan); + h.request("engine.execute", json!({"command": "view.navigator"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); +} + +/// While an edit renders, the loupe shows the last render: it says "Rendering…" once that has +/// lasted a moment (not at once after a spell away from the loupe, when no frames were drawn), +/// and stops when the render lands. +#[test] +fn the_loupe_says_rendering_while_an_edit_renders() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!pill(&h), "a finished render"); + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + // half a second in the grid: the loupe draws no frames + h.request("ui.set", json!({"view": "photoGrid"}), t); + for _ in 0..30 { + h.step(); + } + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + for _ in 0..12 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); + assert!(!pill(&h), "not at once"); + for _ in 0..30 { + h.step(); + } + assert!(pill(&h), "the last render is on screen and the edit's is on its way"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); + assert!(!h.app.renderer.is_pending(Slot::Main)); +} + +/// View Options' Show message when loading or rendering photos (on by default): turned off in +/// the window, the loupe does not say "Rendering…" while an edit renders; `view.viewOptions +/// {loupeMessages}` turns it back on for an agent (refusing a value that is not true or false). +#[test] +fn view_options_turn_the_rendering_message_off() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(h.app.ui.settings.loupe_messages, "on by default"); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.loupeMessages"}), t)["ok"], true); + h.step(); + assert!(!h.app.ui.settings.loupe_messages, "the tick box turns it off"); + h.app.ui.dialog = None; + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + for _ in 0..45 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); + assert!(!pill(&h), "no message while it is off"); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": "yes"}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert!(!h.app.ui.settings.loupe_messages, "a refused change changes nothing"); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": true}}), t); + assert_eq!(r["result"]["loupeMessages"], true, "{r}"); + assert_eq!(h.app.ui.dialog, None, "a value sets it without opening the window"); + assert!(h.step_until(SETTLE, pill), "the message shows again while the render is held"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); +} + +/// With nothing to show yet, the loupe says "Rendering…" in its middle only while View Options' +/// Show message when loading or rendering photos is on. +#[test] +fn an_empty_loupe_says_rendering_only_with_the_message_on() { + use std::{cell::Cell, rc::Rc}; + for on in [true, false] { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.app.ui.settings.loupe_messages = on; + h.app.renderer.set_offload(Box::new(super::RenderGate::new(Rc::new(Cell::new(false))))); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + // drop every texture (quick previews and thumbnails land outside the gate) until a + // frame has nothing to show + let empty = (0..60).any(|_| { + h.app.renderer.textures.clear(); + h.step(); + h.app.loupe_shown.map(|l| l.1) == Some("none") + }); + assert!(empty, "a frame with nothing rendered"); + let said = h.painted_text().iter().any(|text| text == "Rendering…"); + assert_eq!(said, on, "message on: {on}"); + } +} + +/// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left +/// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the +/// error on hover, and keeps saying so while a library change retries it. +#[test] +fn the_loupe_says_so_when_the_render_behind_a_stand_in_fails() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!has(&h, "loupe:renderFailed")); + let first = h.app.session.active().expect("a photo is open").0; + // every loupe render fails, as an undecodable raw's would; the rest render + let open = Rc::new(Cell::new(true)); + h.app.renderer.set_offload(Box::new(super::RenderGate::failing(open.clone(), |s| s == Slot::Main))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo").0; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other], "active": other}}), t); + assert!(h.step_until(SETTLE, |h| has(h, "loupe:renderFailed")), "the failure is said"); + let shown = h.app.loupe_shown.map(|l| l.1); + assert!(!matches!(shown, Some("render" | "none")), "over a stand-in: {shown:?}"); + assert!(!has(&h, "loupe:rendering")); + let r = h.request("ui.inspect", json!({}), t); + assert_eq!(r["result"]["loupe"]["failed"], "no decoder for this raw", "{}", r["result"]["loupe"]); + // a library change (here a rating) retries it: the failure stays said, no "Rendering…" + open.set(false); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 2}}), t); + for _ in 0..45 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the retry is held"); + assert!(has(&h, "loupe:renderFailed") && !has(&h, "loupe:rendering"), "no blink while it retries"); + // back on a photo that rendered: nothing to say + open.set(true); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first], "active": first}}), t); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + assert!(h.step_until(SETTLE, |h| h.app.loupe_shown.is_some_and(|l| l.1 == "render") && !has(h, "loupe:renderFailed"))); +} + +#[test] +fn screenshot_request_is_answered_without_a_window() { + let mut h = demo([800.0, 500.0]); + let r = h.request("ui.screenshot", json!({}), SETTLE); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["width"], 800); + assert_eq!(r["result"]["height"], 500); + let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5)); + assert_eq!(r["ok"], true); + let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE); + assert_eq!(r["result"]["width"], 640, "{r}"); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9ba1cd5af..8be0b3e8e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -564,1212 +564,7 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_cat } #[cfg(test)] -mod tests { - use super::*; - - /// Each language through its menu command and the control channel's locale tags, saved by its - /// code: built-in labels follow it, user values and ids don't, the window paints its text and - /// the bundled fonts have its letters. - #[test] - fn languages_switch_persist_and_keep_user_values() { - use crate::control::{ControlRequest, Outcome}; - let languages: [(Locale, &str, &[&str], &str); 8] = [ - (Locale::Cs, "czech", &["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"], "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“"), - (Locale::Id, "indonesian", &["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"], ""), - (Locale::Sv, "swedish", &["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"], "åäöÅÄÖé”’"), - (Locale::Uk, "ukrainian", &["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"], ""), - (Locale::Ka, "georgian", &["ka", "ka-GE", "ka_GE.UTF-8"], ""), - (Locale::It, "italian", &["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"], ""), - (Locale::Fi, "finnish", &["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"], ""), - (Locale::Vi, "vietnamese", &["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"], ""), - ]; - for (locale, command, tags, letters) in languages { - let command = format!("app.language.{command}"); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run(&command, serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, locale); - assert_eq!(language(), locale); - assert_eq!(crate::menubar::checked(&app, &command), Some(true)); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), locale.tr("Warm Glow")); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(&format!(r#""language":"{}""#, locale.code())), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, locale); - let ctx = egui::Context::default(); - for tag in tags { - assert_eq!(Locale::parse_tag(tag), Some(locale), "{tag}"); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), locale); - } - let text = painted_text(&ctx, &mut app, locale); - // (not the menu titles: a language whose titles don't fit shows one Menu button) - assert!(text.contains(locale.tr("Exposure")), "{locale:?}: {text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in letters.chars() { - assert!(fonts.has_glyph(&font, ch), "{locale:?}: missing glyph {ch}"); - } - } - }); - } - set_language(Locale::En); - } - - #[test] - fn sentence_case_keeps_georgian_lowercase() { - assert_eq!(upper_first("its input is not an image"), "Its input is not an image"); - assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება"); - assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ"); - assert_eq!(upper_first(""), ""); - assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ')); - } - - #[test] - fn every_language_has_a_name_a_script_and_its_own_code() { - assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); - for language in Locale::ALL { - assert!(!language.name().is_empty(), "{language:?} has a name"); - assert!(!language.script().is_empty(), "{language:?} has a script"); - assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); - } - } - - /// Every catalog file is a JSON object of strings, each key once: a malformed file would load as - /// no catalog, and a repeated key silently drops one of its translations. - #[test] - fn catalogs_load() { - let files = catalog_files(); - assert_eq!(files.len(), 2 * (Locale::ALL.len() - 1), "a plain and a formats catalog per translation"); - for path in files { - let repeated = repeated_keys(&path); - assert_eq!(repeated, Ok(Vec::new()), "{}", path.display()); - } - } - - /// `locales/*.json`, sorted. - fn catalog_files() -> Vec { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - let mut files: Vec = std::fs::read_dir(dir) - .unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "json")) - .collect(); - files.sort(); - files - } - - /// The keys a catalog file names more than once, or why it isn't an object of strings. - fn repeated_keys(path: &std::path::Path) -> Result, String> { - struct Repeated; - impl<'de> serde::de::Visitor<'de> for Repeated { - type Value = Vec; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("an object of strings") - } - fn visit_map>(self, mut map: A) -> Result, A::Error> { - let (mut seen, mut repeated) = (std::collections::BTreeSet::new(), Vec::new()); - while let Some((key, _)) = map.next_entry::()? { - if !seen.insert(key.clone()) { - repeated.push(key); - } - } - Ok(repeated) - } - } - let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; - serde::Deserializer::deserialize_map(&mut serde_json::Deserializer::from_str(&text), Repeated).map_err(|error| error.to_string()) - } - - /// A translation carries the placeholders of the message it translates. - #[test] - fn catalogs_agree_on_placeholders() { - let fields = |text: &str| { - let mut out: Vec = Vec::new(); - let mut rest = text; - while let Some(start) = rest.find('{') { - let Some(end) = rest[start..].find('}') else { break }; - out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); - rest = &rest[start + end + 1..]; - } - out.sort(); - out - }; - for language in Locale::ALL { - for (key, value) in language.catalog() { - assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); - } - } - } - - /// Locale tags are matched leniently, so a system locale reaches a shipped language. - /// The message box for a desktop start that failed (issue #260) names the error and the log - /// file, in the UI language. - #[test] - fn startup_failure_message_names_the_error_and_the_log() { - let (title, text) = startup_failed_message("no adapter", Some("/home/a/.config/lightcraft/logs/lightcraft.log")); - assert_eq!(title, "LightCraft could not start"); - assert!(text.contains("no adapter") && text.ends_with("lightcraft.log"), "{text}"); - assert!(!startup_failed_message("no adapter", None).1.contains("log file")); - } - - #[test] - fn locale_tags_normalize() { - assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); - assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); - assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); - assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("fr"), Some(Locale::Fr)); - assert_eq!(Locale::parse_tag("fr-FR"), Some(Locale::Fr)); - assert_eq!(Locale::parse_tag("fr_FR.UTF-8"), Some(Locale::Fr)); - for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}"); - } - for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}"); - } - for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}"); - } - for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Vi), "{tag}"); - } - assert_eq!(Locale::parse_tag("xx"), None); - } - - /// The web host's "Auto" (issue #2085): the browser's `navigator.languages` in order, first - /// shipped language wins, unsupported tags are skipped, an empty or foreign list stays English - /// ([`default_language`] fallback). - #[test] - fn locale_from_tags_takes_the_first_shipped_language() { - assert_eq!(locale_from_tags(&[]), None); - assert_eq!(locale_from_tags(&["xx-XX".into()]), None); - assert_eq!(locale_from_tags(&["it-IT".into()]), Some(Locale::It)); - assert_eq!(locale_from_tags(&["xx".into(), "de-DE".into(), "fr".into()]), Some(Locale::De)); - assert_eq!(locale_from_tags(&["zh-TW".into(), "en".into()]), Some(Locale::ZhHant)); - assert_eq!(locale_from_tags(&["ja_JP.UTF-8".into(), "en".into()]), Some(Locale::Ja)); - assert_eq!(locale_from_tags(&["pt".into(), "pt-BR".into()]), Some(Locale::PtBr)); - } - - #[test] - fn switching_language_translates_and_restores() { - for language in Locale::ALL { - set_language(*language); - for source in ["Exposure", "Settings"] { - assert_eq!(tr(source), language.catalog().get(source).map_or(source, String::as_str), "{language:?}"); - } - // Untranslated and data-like text passes through untouched. - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - assert_eq!(tr("A string no catalog has"), "A string no catalog has"); - } - set_language(Locale::En); - assert_eq!(tr("Exposure"), "Exposure"); - } - - /// 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] - fn formats_render_in_every_language() { - // Every language's format string is checked by `format!` against the call site's values. - set_language(Locale::En); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); - // A message no catalog has formats its English source. - assert_eq!(tr_format!("{} of {} (no catalog has this)", 1, 2), "1 of 2 (no catalog has this)"); - // Format specs survive translation: precision, sign, and values a language reorders; user - // 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}"); - } - set_language(Locale::En); - assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); - } - - /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation - /// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK - /// catalogs are skipped (the same degradation the Japanese UI has always had). - #[test] - fn catalog_characters_are_paintable() { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - for language in Locale::ALL { - let messages = language.catalog(); - if messages.is_empty() { - continue; - } - // The faces this language's script needs; a language the craft-fonts input does not - // cover (a translation added ahead of its font) is reported, not failed. - let mut faces: Vec<&str> = - lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); - faces.dedup(); - if faces.is_empty() { - lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); - continue; - } - 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.clone()); - let mut missing: Vec = messages - .values() - .flat_map(|message| message.chars()) - .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) - .collect(); - missing.sort_unstable(); - missing.dedup(); - assert!( - missing.is_empty(), - "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", - language.code(), - missing.iter().collect::() - ); - } - }); - } - } - - /// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a - /// translation must not carry a symbol no installed font has (egui's defaults cover the rest). - #[test] - fn interface_symbols_are_paintable() { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). - ctx.fonts_mut(|fonts| { - for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { - let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); - assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); - } - }); - } - - /// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped - /// script have no glyph for them and egui's bundled fonts do not either: the menu triangles, - /// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads - /// the same and every face has it. - const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕']; - - #[test] - fn translated_catalogs_do_not_use_missing_menu_triangles() { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.extension().is_none_or(|ext| ext != "json") { - continue; - } - let messages: BTreeMap = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - for (source, text) in messages { - for bad in UNPAINTABLE { - assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display()); - } - } - } - } - - /// The language menu covers every language, and a language's own command selects it. - #[test] - fn language_commands_cover_every_language() { - let commands = [ - ("app.language.traditionalChinese", Locale::ZhHant), - ("app.language.english", Locale::En), - ("app.language.simplifiedChinese", Locale::ZhHans), - ("app.language.japanese", Locale::Ja), - ("app.language.portuguese", Locale::PtBr), - ("app.language.french", Locale::Fr), - ("app.language.spanish", Locale::Es), - ("app.language.german", Locale::De), - ("app.language.russian", Locale::Ru), - ("app.language.ukrainian", Locale::Uk), - ("app.language.polish", Locale::Pl), - ("app.language.czech", Locale::Cs), - ("app.language.italian", Locale::It), - ("app.language.georgian", Locale::Ka), - ("app.language.swedish", Locale::Sv), - ("app.language.finnish", Locale::Fi), - ("app.language.indonesian", Locale::Id), - ("app.language.vietnamese", Locale::Vi), - ]; - // One command per language, and every command reachable from the menu table. - assert_eq!(commands.len(), Locale::ALL.len()); - for (id, language) in commands { - assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); - assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); - } - assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); - assert_eq!(crate::menus::language_from_command("view.detail"), None); - } - - #[test] - fn preferences_round_trip_and_old_settings_remain_readable() { - let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); - assert_eq!(old.language, Locale::En); - // Settings written before the language list grew still load. - let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); - assert_eq!(legacy.language, Locale::Ja); - let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; - let saved = serde_json::to_string(&settings).unwrap(); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Locale::ZhHans); - } - - /// Smart-album choice values read as words in every language (English "Public Domain"), never as - /// rule ids like `publicDomain`; an id the field doesn't know shows as written. A yes/no value - /// reads Yes / No, or the language's contextual entry for it where it has one. - #[test] - fn choice_values_read_as_words_in_every_language() { - use lightcraft_catalog::rules::{FIELDS, Kind, bool_label}; - assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); - assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); - assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); - for language in Locale::ALL { - assert_eq!(choice_text_in(*language, "copyrightStatus", "someday"), "someday"); - assert_eq!(tr_ctx_in(*language, "nope", "Yes"), language.tr("Yes"), "no contextual entry: the plain message"); - for b in [true, false] { - let contextual = language.catalog().get(&format!("{BOOL_CONTEXT}|{}", bool_label(b))); - assert_eq!(bool_text_in(*language, b), contextual.map_or(language.tr(bool_label(b)), String::as_str)); - } - for (field, _, kind) in FIELDS { - let Kind::Choice(choices) = kind else { continue }; - for (id, _) in *choices { - assert_ne!(choice_text_in(*language, field, id), *id, "{}: {field} {id}", language.code()); - } - } - } - // a contextual entry names a real value (a typo would silently fall back to the plain Yes / No) - let values = [bool_label(true), bool_label(false)]; - for language in Locale::ALL { - for key in language.catalog().keys() { - if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { - assert!(values.contains(&value), "{}: {key:?} isn't a yes/no value", language.code()); - } - } - } - } - - /// A rule's problem reads as the field's name and the issue, in the editor's language, joined by - /// a full-width colon in Japanese and Chinese; the sidebar's list puts the rule's position first. - #[test] - fn problems_read_in_every_language() { - use lightcraft_catalog::rules::Issue; - let cat = lightcraft_catalog::Catalog::new(); - let problems = |rules: serde_json::Value| { - serde_json::from_value::(serde_json::json!({"rules": rules})).unwrap().check(&cat) - }; - let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); - assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); - assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); - for language in Locale::ALL { - let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; - let text = format!("{}{colon}{}", language.tr("Title"), language.tr(p[0].issue.text())); - assert_eq!(problem_text_in(*language, &p[0]), text); - assert_eq!(problem_line_in(*language, &p[0]), format!("#2 {text}")); - } - // a problem with the album filter itself has no rule position to show - let filter_loop = - lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; - assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); - let g = problems(serde_json::json!([{"group": {"rules": []}}])); - assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); - } - - #[test] - fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains(Locale::Uk.tr("My Photos")) && text.contains(Locale::Uk.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains(Locale::Uk.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ҐґЄєІіЇї’«»" - .chars() - .chain(Locale::Uk.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{0400}'..='\u{04ff}').contains(ch))) - { - assert!(fonts.has_glyph(&font, ch), "missing Ukrainian glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains(Locale::It.tr("My Photos")) && text.contains(Locale::It.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains(Locale::It.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" - .chars() - .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) - { - assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains(Locale::Fi.tr("My Photos")) && text.contains(Locale::Fi.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains(Locale::Fi.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "äöåÄÖÅ”–" - .chars() - .chain(Locale::Fi.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) - { - assert!(fonts.has_glyph(&font, ch), "missing Finnish glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn german_switches_via_menu_and_control_and_persists() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.german", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::De); - assert_eq!(language(), Locale::De); - assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"de""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::De); - let ctx = egui::Context::default(); - for code in ["de-DE", "de_AT.UTF-8", "de-CH"] { - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::De); - } - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), Locale::De.tr("Warm Glow")); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }], - }; - let mut catalog = lightcraft_catalog::Catalog::new(); - catalog - .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) - .unwrap(); - // "Color" is a message too, but a rule's free-text value stays as written - let keywords = lightcraft_catalog::rules::field_label("keywords").unwrap_or("keywords"); - assert_eq!(rules_label(&rules, &catalog), format!("{} {} Color", Locale::De.tr(keywords), Locale::De.tr("contains"))); - let album = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], - }; - assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { - field: "copyrightStatus".into(), - op: "is".into(), - value: serde_json::json!("publicDomain"), - }], - }; - let public_domain = format!(" {}", choice_text_in(Locale::De, "copyrightStatus", "publicDomain")); - assert!(rules_label(&rules, &catalog).ends_with(&public_domain), "{}", rules_label(&rules, &catalog)); - for value in [serde_json::json!(false), serde_json::json!("false")] { - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], - }; - let no = format!(" {}", bool_text_in(Locale::De, false)); - assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); - } - let both = lightcraft_catalog::Filter { - label: Some(lightcraft_catalog::ColorLabel::Red), - labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Red], - ..Default::default() - }; - let name = |label| color_label(&app.session.catalog, label); - let (red, blue, green) = - (name(lightcraft_catalog::ColorLabel::Red), name(lightcraft_catalog::ColorLabel::Blue), name(lightcraft_catalog::ColorLabel::Green)); - let (label, and, or) = (tr("Label"), tr("and"), tr("or")); - assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red}")); - let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue], ..both }; - assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} {blue}")); - let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Green], ..both }; - assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} ({blue} {or} {green})")); - let filter = lightcraft_catalog::Filter { - labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], - ..Default::default() - }; - let blue = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Blue); - let red = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Red); - let label = filter_label(&filter, &app.session.catalog); - assert!(label.contains(&red) && label.contains(&blue), "{label}"); - // a label set's own name is the user's - app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); - let label = filter_label(&filter, &app.session.catalog); - assert!(label.contains("Color") && !label.contains(&red) && label.contains(&blue), "{label}"); - set_language(Locale::En); - } - - #[test] - fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(Locale::De.tr("My Photos")) && text.contains(Locale::De.tr("All Photos")), "{text}"); - app.ui.view = crate::state::ViewMode::People; - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(Locale::De.tr("Named People")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(Locale::De.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ÄÖÜäöüß„“".chars() { - assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn german_ukrainian_italian_and_finnish_buttons_fit_the_bottom_bar_and_export_dialog() { - fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { - match shape { - egui::epaint::Shape::Text(shape) => { - bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size()))) - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)), - _ => {} - } - } - for language in [Locale::De, Locale::Uk, Locale::It, Locale::Fi] { - let ctx = fonts_ctx(&[]); - let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - app.ui.language = language; - let mut bounds = Vec::new(); - for export in [false, true] { - if export { - app.run("dialog.export", serde_json::json!({})).unwrap(); - } - for frame in 0..4 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - bounds.clear(); - for shape in out.shapes { - collect(&shape.shape, &mut bounds); - } - } - let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1; - if export { - let dialog = rect("dialog:window"); - for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] { - assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id)); - } - } else { - let button = rect("button:copySettings"); - let (_, text) = bounds.iter().find(|entry| entry.0 == language.tr("Copy Edit Settings")).unwrap(); - assert!(button.contains_rect(*text), "{language:?} copy caption overflows its button: {text:?} vs {button:?}"); - assert!(!button.intersects(rect("icon:copyGear"))); - } - } - } - set_language(Locale::En); - } - - /// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. - #[test] - fn menu_labels_translate_but_user_names_survive() { - for language in Locale::ALL { - set_language(*language); - assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); - } - set_language(Locale::En); - } - - /// The text the whole window paints over a few frames in `language`. - fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { - fn collect(shape: &egui::epaint::Shape, text: &mut String) { - match shape { - egui::epaint::Shape::Text(shape) => { - text.push_str(&shape.galley.job.text); - text.push('\n'); - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), - _ => {} - } - } - app.ui.language = language; - let mut text = String::new(); - for frame in 0..4 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - // This inspects shapes without a renderer; discard texture uploads explicitly. - out.textures_delta.clear(); - text.clear(); - for shape in out.shapes { - collect(&shape.shape, &mut text); - } - } - text - } - - /// Every language is painted by the real window, switching at runtime reinstalls the fonts - /// (their CJK fallback order follows the language), and command ids never change with it. - #[test] - fn every_language_is_painted_and_menu_ids_stay_the_same() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); - let english_ids = ids(&app); - for language in Locale::ALL { - let text = painted_text(&ctx, &mut app, *language); - for label in ["My Photos", "All Photos"] { - assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); - } - assert_eq!(app.font_language, *language, "the fonts follow the language"); - assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); - } - set_language(Locale::En); - } - - /// The UI families, after one frame so the font definitions are loaded. - fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(craft)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - ctx - } - - /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI - /// family, Monospace included. - #[test] - fn craft_fonts_render_cjk_in_the_ui() { - let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; - for (language, sample) in samples { - if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); - continue; - } - set_language(language); - let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); - ctx.fonts_mut(|fonts| { - for family in - [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] - { - let font = egui::FontId::new(13.0, family); - for ch in sample.chars() { - assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); - } - let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); - let wide = 13.0 * (sample.chars().count() as f32 - 1.0); - assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); - } - }); - } - set_language(Locale::En); - } - - /// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; - /// Latin text keeps Inter. - #[test] - fn the_ui_works_without_craft_fonts() { - let ctx = fonts_ctx(&[]); - ctx.fonts_mut(|fonts| { - // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's - // replacement-glyph face, which there is Hack, the face that draws Latin.) - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); - } - }); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - for language in Locale::ALL { - app.ui.language = *language; - for frame in 0..3 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - } - } - set_language(Locale::En); - } - - /// Traditional Chinese through the menu command and the control channel, persisted, with - /// localised headings; ids and user text are untouched. - #[test] - fn traditional_chinese_switches_through_menu_and_control_and_persists() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::ZhHant); - // Applied at once, not on the next frame. - assert_eq!(language(), Locale::ZhHant); - assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), Locale::ZhHant.tr("All Photos")); - assert_eq!(date_group_label("", false), Locale::ZhHant.tr("Unknown Date")); - assert_eq!(display_time("a-user-value"), "a-user-value"); - assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); - assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { - assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); - } - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), Locale::ZhHant.tr("Color")); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant); - let ctx = egui::Context::default(); - app.ui.language = Locale::En; - for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] { - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; - assert_eq!(reply["ok"], true, "{code}"); - assert_eq!(app.ui.language, Locale::ZhHant, "{code}"); - assert_eq!(language(), Locale::ZhHant, "{code} applies at once"); - } - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; - assert_eq!(reply["ok"], false); - assert_eq!(app.ui.language, Locale::ZhHant); - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); - let chinese_ids = ids(&app); - app.run("app.language.english", serde_json::json!({})).unwrap(); - assert_eq!(tr("File"), "File"); - assert_eq!(chinese_ids, ids(&app)); - } - - /// Dates follow every language's own patterns (English keeps the catalog's wording); a key that - /// is not a date never panics and never shows a raw pattern. - #[test] - fn date_headings_in_every_language() { - set_language(Locale::En); - assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026"); - assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); - assert_eq!(date_group_label("2026-09", true), "September"); - assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); - for language in Locale::ALL { - set_language(*language); - if *language != Locale::En { - let day = date_group_label("2026-09-20", false); - assert!(day.contains("20") && day.contains("2026") && day.contains(tr("Sunday")), "{language:?}: {day}"); - assert!(date_group_label("2026-09-20", true).contains("20"), "{language:?}"); - assert!(date_group_label("2026-09", false).contains("2026"), "{language:?}"); - assert!(date_group_label("2026", false).contains("2026"), "{language:?}"); - assert_eq!(display_time("2026-09-20T16:04:05"), format!("{day} 16:04:05"), "{language:?}"); - } - for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { - assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); - assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); - } - } - set_language(Locale::En); - } - - /// Stock presets and panel headers are translated; a user preset keeps its name, also when it - /// shares a stock preset's name. - #[test] - fn traditional_chinese_presets_and_panel_headers_are_painted() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.presets = true; - let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); - app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); - let zh = Locale::ZhHant; - for (panel, title) in [(crate::state::RightPanel::Activity, "History"), (crate::state::RightPanel::Versions, "Versions")] { - app.ui.right = panel; - let text = painted_text(&ctx, &mut app, zh); - assert!(text.contains(zh.tr(title)), "{text}"); - assert!(text.contains(zh.tr("Warm Glow")), "stock preset: {text}"); - assert!(text.contains("Warm Glow"), "user preset: {text}"); - assert!(text.contains(zh.tr("Color")), "stock group: {text}"); - } - assert_eq!(history_label("Exposure", &app.session.presets), zh.tr("Exposure")); - assert!(history_label("Preset: Warm Film", &app.session.presets).ends_with(zh.tr("Warm Film"))); - assert!(history_label("Preset: Warm Glow", &app.session.presets).ends_with("Warm Glow"), "the user's preset shares the name"); - set_language(Locale::En); - } - - #[test] - fn vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains(Locale::Vi.tr("My Photos")) && text.contains(Locale::Vi.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains(Locale::Vi.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ĂăÂâÊêÔôƠơƯưĐđáàảãạéèẻẽẹíìỉĩịóòỏõọúùủũụýỳỷỹỵ".chars().chain( - Locale::Vi - .catalog() - .values() - .flat_map(|text| text.chars()) - .filter(|ch| ('\u{00c0}'..='\u{024f}').contains(ch) || ('\u{1ea0}'..='\u{1ef9}').contains(ch)), - ) { - assert!(fonts.has_glyph(&font, ch), "missing Vietnamese glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - /// The messages each language lacks, which it shows in English. Never fails: `cargo xtask ci` - /// runs it with `--nocapture`, so the list lands in the gate log. Plain messages are every key - /// some catalog has plus the labels the UI passes to `tr` from tables (commands, controls, rule - /// fields…); formats are every key some `*-formats.json` has plus every `tr_format!` message in - /// the app's source. An empty translation counts as missing. - #[test] - fn translation_gaps_report() { - let notes = whats_new_lines(); - let messages: std::collections::BTreeSet = plain_messages().into_iter().filter(|m| !notes.contains(&m.as_str())).collect(); - let formats = format_messages(); - let quoted = |list: &[&str]| list.iter().map(|m| format!("{m:?}")).collect::>().join(", "); - let mut out = format!( - "Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", - messages.len(), - formats.len(), - notes.len() - ); - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - let catalog = language.catalog(); - let own_formats = formats_catalog(language.code()); - let missing: Vec<&str> = messages.iter().map(String::as_str).filter(|m| !catalog.contains_key(*m)).collect(); - let missing_formats: Vec<&str> = formats.iter().map(String::as_str).filter(|m| !own_formats.contains_key(*m)).collect(); - let missing_notes = notes.iter().filter(|line| !catalog.contains_key(**line)).count(); - out.push_str(&format!( - "{}: {} messages, {} formats, {} What's New lines\n", - language.code(), - missing.len(), - missing_formats.len(), - missing_notes - )); - if !missing.is_empty() { - out.push_str(&format!(" messages: {}\n", quoted(&missing))); - } - if !missing_formats.is_empty() { - out.push_str(&format!(" formats: {}\n", quoted(&missing_formats))); - } - } - println!("{out}"); - } - - /// Every plain message: the keys of every catalog (but contextual entries, which a language adds - /// only where it needs one) and the labels the UI translates from tables. - fn plain_messages() -> std::collections::BTreeSet { - use lightcraft_catalog::rules; - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in rules::FIELDS { - labels.push(label); - labels.extend(rules::ops_for(*kind).iter().map(|(_, label)| *label)); - if let rules::Kind::Choice(choices) = kind { - labels.extend(choices.iter().map(|(_, label)| *label)); - } - } - labels.extend(rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(rules::Issue::ALL.iter().map(|issue| issue.text())); - labels.extend([rules::bool_label(true), rules::bool_label(false)]); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - labels.extend(crate::date_picker::WEEKDAY_SHORT); - labels.extend(["Pick a date", "Year", "Month", "Day", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]); - // the album pickers - labels.extend([ - "Choose an album…", - "No albums yet", - "Search albums", - "No albums match", - "This is the album you're editing", - "It tests this album, so testing it back would loop", - "Fix the marked rules to save this album.", - "None", - "New album", - "New album…", - "Imported Photos", - ]); - labels.extend(crate::panels::keyword_list::VARIABLE_LABELS); - let contextual = format!("{BOOL_CONTEXT}|"); - Locale::ALL - .iter() - .flat_map(|language| language.catalog().keys()) - .filter(|key| !key.starts_with(&contextual)) - .cloned() - .chain(labels.into_iter().map(str::to_string)) - .collect() - } - - /// Help ▸ What's New, line by line as the dialog looks each one up. - fn whats_new_lines() -> Vec<&'static str> { - crate::panels::dialogs::WHATS_NEW - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with("# ")) - .map(|line| line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line)) - .collect() - } - - /// `locales/-formats.json` (the catalog `build.rs` compiles into `tr_format!`), without - /// empty translations. - fn formats_catalog(code: &str) -> BTreeMap { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("locales/{code}-formats.json")); - let mut messages: BTreeMap = - std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default(); - messages.retain(|_, text| !text.is_empty()); - messages - } - - /// Every format message: the keys of every formats catalog and the message of every - /// `tr_format!` call in `src/` outside its test modules. - fn format_messages() -> std::collections::BTreeSet { - let mut all: std::collections::BTreeSet = Locale::ALL - .iter() - .filter(|language| **language != Locale::En) - .flat_map(|language| formats_catalog(language.code()).into_keys()) - .collect(); - let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - while let Some(dir) = dirs.pop() { - for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { - if path.is_dir() { - dirs.push(path); - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(&path).unwrap(); - let app = source - .split( - "#[cfg(test)] -mod ", - ) - .next() - .unwrap_or_default(); - all.extend(tr_format_messages(app)); - } - } - } - all - } - - /// The first argument of each `tr_format!` call in `source`, when it is a string literal. - fn tr_format_messages(source: &str) -> Vec { - // built from parts, so this file's own source holds no call to find - let call = concat!("tr_format", "!("); - let mut messages = Vec::new(); - for (at, _) in source.match_indices(call) { - let Some(body) = source[at + call.len()..].trim_start().strip_prefix('"') else { continue }; - let mut message = String::new(); - let mut chars = body.chars(); - while let Some(ch) = chars.next() { - match ch { - '"' => break, - '\\' => match chars.next() { - Some('n') => message.push('\n'), - Some('t') => message.push('\t'), - Some('u') => { - let hex: String = chars.by_ref().skip(1).take_while(|ch| *ch != '}').collect(); - message.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); - } - Some(other) => message.push(other), - None => break, - }, - _ => message.push(ch), - } - } - messages.push(message); - } - messages - } - - #[test] - fn tr_format_messages_are_read_from_source() { - let call = concat!("tr_format", "!("); - let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); - assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); - } - - /// A drop's notice comes from what `library.import` reported: a photo added, one brought back from Recently - /// Deleted and a file that could not be read are each named; a drop of duplicates only says nothing. - #[test] - fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_duplicates_only() { - set_language(Locale::En); - let dir = std::env::temp_dir().join(format!("lc-drop-notice-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let png = |name: &str, seed: u8| { - let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data }; - let bytes = - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(name), bytes).unwrap(); - dir.join(name).to_string_lossy().into_owned() - }; - let (trashed, added) = (png("trashed.png", 1), png("new.png", 2)); - let broken = dir.join("broken.jpg"); - std::fs::write(&broken, b"not a jpeg at all").unwrap(); - let mut s = lightcraft_engine::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [trashed]})).unwrap(); - s.execute("photo.delete", &serde_json::json!({"ids": [r["imported"][0]]})).unwrap(); - let report = s.execute("library.import", &serde_json::json!({"paths": [trashed, added, broken]})).unwrap(); - assert_eq!( - dropped_photos_outcome(&report).as_deref(), - Some("Added 1 photo · Restored 1 photo from Recently Deleted · 1 photo not readable"), - "{report}" - ); - let again = s.execute("library.import", &serde_json::json!({"paths": [added]})).unwrap(); - assert_eq!(dropped_photos_outcome(&again), None, "a duplicate only: {again}"); - let _ = std::fs::remove_dir_all(dir); - } - - /// Polish: the menu and control channel select it, dates read day.month.year, and every count - /// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so - /// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. - #[test] - fn polish_switches_formats_dates_and_reads_every_count() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.polish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Pl); - assert_eq!(Locale::Pl.name(), "Polski"); - assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); - assert_eq!(tr("File"), "Plik"); - assert_eq!(tr("Exposure"), "Ekspozycja"); - assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); - assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); - for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { - let s = if n == 1 { "" } else { "s" }; - assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); - for (one, many, noun) in [ - ("photo waiting", "photos waiting", "Oczekujące zdjęcia"), - ("cached picture", "cached pictures", "Obrazy w pamięci podręcznej"), - ("photo has its denoised picture.", "photos have their denoised picture.", "Zdjęcia z odszumioną wersją"), - ] { - assert_eq!(tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })), format!("{noun}: {n}")); - } - assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); - assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); - } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); - let ctx = egui::Context::default(); - for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Pl); - } - let text = painted_text(&ctx, &mut app, Locale::Pl); - assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ„”".chars() { - assert!(fonts.has_glyph(&font, ch), "missing Polish glyph {ch}"); - } - } - }); - set_language(Locale::En); - } -} +mod tests; #[cfg(test)] mod picker_order_tests { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 000000000..2a44072f0 --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,1185 @@ +use super::*; + +/// Each language through its menu command and the control channel's locale tags, saved by its +/// code: built-in labels follow it, user values and ids don't, the window paints its text and +/// the bundled fonts have its letters. +#[test] +fn languages_switch_persist_and_keep_user_values() { + use crate::control::{ControlRequest, Outcome}; + let languages: [(Locale, &str, &[&str], &str); 8] = [ + (Locale::Cs, "czech", &["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"], "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“"), + (Locale::Id, "indonesian", &["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"], ""), + (Locale::Sv, "swedish", &["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"], "åäöÅÄÖé”’"), + (Locale::Uk, "ukrainian", &["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"], ""), + (Locale::Ka, "georgian", &["ka", "ka-GE", "ka_GE.UTF-8"], ""), + (Locale::It, "italian", &["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"], ""), + (Locale::Fi, "finnish", &["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"], ""), + (Locale::Vi, "vietnamese", &["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"], ""), + ]; + for (locale, command, tags, letters) in languages { + let command = format!("app.language.{command}"); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run(&command, serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, locale); + assert_eq!(language(), locale); + assert_eq!(crate::menubar::checked(&app, &command), Some(true)); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), locale.tr("Warm Glow")); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(&format!(r#""language":"{}""#, locale.code())), "{saved}"); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, locale); + let ctx = egui::Context::default(); + for tag in tags { + assert_eq!(Locale::parse_tag(tag), Some(locale), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), locale); + } + let text = painted_text(&ctx, &mut app, locale); + // (not the menu titles: a language whose titles don't fit shows one Menu button) + assert!(text.contains(locale.tr("Exposure")), "{locale:?}: {text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in letters.chars() { + assert!(fonts.has_glyph(&font, ch), "{locale:?}: missing glyph {ch}"); + } + } + }); + } + set_language(Locale::En); +} + +#[test] +fn sentence_case_keeps_georgian_lowercase() { + assert_eq!(upper_first("its input is not an image"), "Its input is not an image"); + assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება"); + assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ"); + assert_eq!(upper_first(""), ""); + assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ')); +} + +#[test] +fn every_language_has_a_name_a_script_and_its_own_code() { + assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); + for language in Locale::ALL { + assert!(!language.name().is_empty(), "{language:?} has a name"); + assert!(!language.script().is_empty(), "{language:?} has a script"); + assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); + } +} + +/// Every catalog file is a JSON object of strings, each key once: a malformed file would load as +/// no catalog, and a repeated key silently drops one of its translations. +#[test] +fn catalogs_load() { + let files = catalog_files(); + assert_eq!(files.len(), 2 * (Locale::ALL.len() - 1), "a plain and a formats catalog per translation"); + for path in files { + let repeated = repeated_keys(&path); + assert_eq!(repeated, Ok(Vec::new()), "{}", path.display()); + } +} + +/// `locales/*.json`, sorted. +fn catalog_files() -> Vec { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let mut files: Vec = + std::fs::read_dir(dir).unwrap().map(|entry| entry.unwrap().path()).filter(|path| path.extension().is_some_and(|ext| ext == "json")).collect(); + files.sort(); + files +} + +/// The keys a catalog file names more than once, or why it isn't an object of strings. +fn repeated_keys(path: &std::path::Path) -> Result, String> { + struct Repeated; + impl<'de> serde::de::Visitor<'de> for Repeated { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an object of strings") + } + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let (mut seen, mut repeated) = (std::collections::BTreeSet::new(), Vec::new()); + while let Some((key, _)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + repeated.push(key); + } + } + Ok(repeated) + } + } + let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; + serde::Deserializer::deserialize_map(&mut serde_json::Deserializer::from_str(&text), Repeated).map_err(|error| error.to_string()) +} + +/// A translation carries the placeholders of the message it translates. +#[test] +fn catalogs_agree_on_placeholders() { + let fields = |text: &str| { + let mut out: Vec = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find('{') { + let Some(end) = rest[start..].find('}') else { break }; + out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); + rest = &rest[start + end + 1..]; + } + out.sort(); + out + }; + for language in Locale::ALL { + for (key, value) in language.catalog() { + assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); + } + } +} + +/// Locale tags are matched leniently, so a system locale reaches a shipped language. +/// The message box for a desktop start that failed (issue #260) names the error and the log +/// file, in the UI language. +#[test] +fn startup_failure_message_names_the_error_and_the_log() { + let (title, text) = startup_failed_message("no adapter", Some("/home/a/.config/lightcraft/logs/lightcraft.log")); + assert_eq!(title, "LightCraft could not start"); + assert!(text.contains("no adapter") && text.ends_with("lightcraft.log"), "{text}"); + assert!(!startup_failed_message("no adapter", None).1.contains("log file")); +} + +#[test] +fn locale_tags_normalize() { + assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); + assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("fr"), Some(Locale::Fr)); + assert_eq!(Locale::parse_tag("fr-FR"), Some(Locale::Fr)); + assert_eq!(Locale::parse_tag("fr_FR.UTF-8"), Some(Locale::Fr)); + for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}"); + } + for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}"); + } + for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}"); + } + for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Vi), "{tag}"); + } + assert_eq!(Locale::parse_tag("xx"), None); +} + +/// The web host's "Auto" (issue #2085): the browser's `navigator.languages` in order, first +/// shipped language wins, unsupported tags are skipped, an empty or foreign list stays English +/// ([`default_language`] fallback). +#[test] +fn locale_from_tags_takes_the_first_shipped_language() { + assert_eq!(locale_from_tags(&[]), None); + assert_eq!(locale_from_tags(&["xx-XX".into()]), None); + assert_eq!(locale_from_tags(&["it-IT".into()]), Some(Locale::It)); + assert_eq!(locale_from_tags(&["xx".into(), "de-DE".into(), "fr".into()]), Some(Locale::De)); + assert_eq!(locale_from_tags(&["zh-TW".into(), "en".into()]), Some(Locale::ZhHant)); + assert_eq!(locale_from_tags(&["ja_JP.UTF-8".into(), "en".into()]), Some(Locale::Ja)); + assert_eq!(locale_from_tags(&["pt".into(), "pt-BR".into()]), Some(Locale::PtBr)); +} + +#[test] +fn switching_language_translates_and_restores() { + for language in Locale::ALL { + set_language(*language); + for source in ["Exposure", "Settings"] { + assert_eq!(tr(source), language.catalog().get(source).map_or(source, String::as_str), "{language:?}"); + } + // Untranslated and data-like text passes through untouched. + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(tr("A string no catalog has"), "A string no catalog has"); + } + set_language(Locale::En); + assert_eq!(tr("Exposure"), "Exposure"); +} + +/// 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] +fn formats_render_in_every_language() { + // Every language's format string is checked by `format!` against the call site's values. + set_language(Locale::En); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); + // A message no catalog has formats its English source. + assert_eq!(tr_format!("{} of {} (no catalog has this)", 1, 2), "1 of 2 (no catalog has this)"); + // Format specs survive translation: precision, sign, and values a language reorders; user + // 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}"); + } + set_language(Locale::En); + assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); +} + +/// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation +/// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK +/// catalogs are skipped (the same degradation the Japanese UI has always had). +#[test] +fn catalog_characters_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + for language in Locale::ALL { + let messages = language.catalog(); + if messages.is_empty() { + continue; + } + // The faces this language's script needs; a language the craft-fonts input does not + // cover (a translation added ahead of its font) is reported, not failed. + let mut faces: Vec<&str> = + lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); + faces.dedup(); + if faces.is_empty() { + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + 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.clone()); + let mut missing: Vec = + messages.values().flat_map(|message| message.chars()).filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)).collect(); + missing.sort_unstable(); + missing.dedup(); + assert!( + missing.is_empty(), + "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", + language.code(), + missing.iter().collect::() + ); + } + }); + } +} + +/// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a +/// translation must not carry a symbol no installed font has (egui's defaults cover the rest). +#[test] +fn interface_symbols_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). + ctx.fonts_mut(|fonts| { + for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); + } + }); +} + +/// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped +/// script have no glyph for them and egui's bundled fonts do not either: the menu triangles, +/// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads +/// the same and every face has it. +const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕']; + +#[test] +fn translated_catalogs_do_not_use_missing_menu_triangles() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let messages: BTreeMap = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + for (source, text) in messages { + for bad in UNPAINTABLE { + assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display()); + } + } + } +} + +/// The language menu covers every language, and a language's own command selects it. +#[test] +fn language_commands_cover_every_language() { + let commands = [ + ("app.language.traditionalChinese", Locale::ZhHant), + ("app.language.english", Locale::En), + ("app.language.simplifiedChinese", Locale::ZhHans), + ("app.language.japanese", Locale::Ja), + ("app.language.portuguese", Locale::PtBr), + ("app.language.french", Locale::Fr), + ("app.language.spanish", Locale::Es), + ("app.language.german", Locale::De), + ("app.language.russian", Locale::Ru), + ("app.language.ukrainian", Locale::Uk), + ("app.language.polish", Locale::Pl), + ("app.language.czech", Locale::Cs), + ("app.language.italian", Locale::It), + ("app.language.georgian", Locale::Ka), + ("app.language.swedish", Locale::Sv), + ("app.language.finnish", Locale::Fi), + ("app.language.indonesian", Locale::Id), + ("app.language.vietnamese", Locale::Vi), + ]; + // One command per language, and every command reachable from the menu table. + assert_eq!(commands.len(), Locale::ALL.len()); + for (id, language) in commands { + assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); + assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); + } + assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); + assert_eq!(crate::menus::language_from_command("view.detail"), None); +} + +#[test] +fn preferences_round_trip_and_old_settings_remain_readable() { + let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(old.language, Locale::En); + // Settings written before the language list grew still load. + let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); + assert_eq!(legacy.language, Locale::Ja); + let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; + let saved = serde_json::to_string(&settings).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Locale::ZhHans); +} + +/// Smart-album choice values read as words in every language (English "Public Domain"), never as +/// rule ids like `publicDomain`; an id the field doesn't know shows as written. A yes/no value +/// reads Yes / No, or the language's contextual entry for it where it has one. +#[test] +fn choice_values_read_as_words_in_every_language() { + use lightcraft_catalog::rules::{FIELDS, Kind, bool_label}; + assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); + assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); + assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); + for language in Locale::ALL { + assert_eq!(choice_text_in(*language, "copyrightStatus", "someday"), "someday"); + assert_eq!(tr_ctx_in(*language, "nope", "Yes"), language.tr("Yes"), "no contextual entry: the plain message"); + for b in [true, false] { + let contextual = language.catalog().get(&format!("{BOOL_CONTEXT}|{}", bool_label(b))); + assert_eq!(bool_text_in(*language, b), contextual.map_or(language.tr(bool_label(b)), String::as_str)); + } + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, _) in *choices { + assert_ne!(choice_text_in(*language, field, id), *id, "{}: {field} {id}", language.code()); + } + } + } + // a contextual entry names a real value (a typo would silently fall back to the plain Yes / No) + let values = [bool_label(true), bool_label(false)]; + for language in Locale::ALL { + for key in language.catalog().keys() { + if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { + assert!(values.contains(&value), "{}: {key:?} isn't a yes/no value", language.code()); + } + } + } +} + +/// A rule's problem reads as the field's name and the issue, in the editor's language, joined by +/// a full-width colon in Japanese and Chinese; the sidebar's list puts the rule's position first. +#[test] +fn problems_read_in_every_language() { + use lightcraft_catalog::rules::Issue; + let cat = lightcraft_catalog::Catalog::new(); + let problems = + |rules: serde_json::Value| serde_json::from_value::(serde_json::json!({"rules": rules})).unwrap().check(&cat); + let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); + assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); + assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); + for language in Locale::ALL { + let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; + let text = format!("{}{colon}{}", language.tr("Title"), language.tr(p[0].issue.text())); + assert_eq!(problem_text_in(*language, &p[0]), text); + assert_eq!(problem_line_in(*language, &p[0]), format!("#2 {text}")); + } + // a problem with the album filter itself has no rule position to show + let filter_loop = + lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; + assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); + let g = problems(serde_json::json!([{"group": {"rules": []}}])); + assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); +} + +#[test] +fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Uk); + assert!(text.contains(Locale::Uk.tr("My Photos")) && text.contains(Locale::Uk.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Uk); + assert!(text.contains(Locale::Uk.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ҐґЄєІіЇї’«»" + .chars() + .chain(Locale::Uk.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{0400}'..='\u{04ff}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Ukrainian glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(Locale::It.tr("My Photos")) && text.contains(Locale::It.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(Locale::It.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" + .chars() + .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Fi); + assert!(text.contains(Locale::Fi.tr("My Photos")) && text.contains(Locale::Fi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Fi); + assert!(text.contains(Locale::Fi.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "äöåÄÖÅ”–" + .chars() + .chain(Locale::Fi.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Finnish glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn german_switches_via_menu_and_control_and_persists() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.german", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::De); + assert_eq!(language(), Locale::De); + assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"de""#)); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::De); + let ctx = egui::Context::default(); + for code in ["de-DE", "de_AT.UTF-8", "de-CH"] { + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::De); + } + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), Locale::De.tr("Warm Glow")); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }], + }; + let mut catalog = lightcraft_catalog::Catalog::new(); + catalog + .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) + .unwrap(); + // "Color" is a message too, but a rule's free-text value stays as written + let keywords = lightcraft_catalog::rules::field_label("keywords").unwrap_or("keywords"); + assert_eq!(rules_label(&rules, &catalog), format!("{} {} Color", Locale::De.tr(keywords), Locale::De.tr("contains"))); + let album = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], + }; + assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "copyrightStatus".into(), op: "is".into(), value: serde_json::json!("publicDomain") }], + }; + let public_domain = format!(" {}", choice_text_in(Locale::De, "copyrightStatus", "publicDomain")); + assert!(rules_label(&rules, &catalog).ends_with(&public_domain), "{}", rules_label(&rules, &catalog)); + for value in [serde_json::json!(false), serde_json::json!("false")] { + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], + }; + let no = format!(" {}", bool_text_in(Locale::De, false)); + assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); + } + let both = lightcraft_catalog::Filter { + label: Some(lightcraft_catalog::ColorLabel::Red), + labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Red], + ..Default::default() + }; + let name = |label| color_label(&app.session.catalog, label); + let (red, blue, green) = + (name(lightcraft_catalog::ColorLabel::Red), name(lightcraft_catalog::ColorLabel::Blue), name(lightcraft_catalog::ColorLabel::Green)); + let (label, and, or) = (tr("Label"), tr("and"), tr("or")); + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} {blue}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Green], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} ({blue} {or} {green})")); + let filter = + lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], ..Default::default() }; + let blue = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Blue); + let red = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Red); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains(&red) && label.contains(&blue), "{label}"); + // a label set's own name is the user's + app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains("Color") && !label.contains(&red) && label.contains(&blue), "{label}"); + set_language(Locale::En); +} + +#[test] +fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains(Locale::De.tr("My Photos")) && text.contains(Locale::De.tr("All Photos")), "{text}"); + app.ui.view = crate::state::ViewMode::People; + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains(Locale::De.tr("Named People")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains(Locale::De.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ÄÖÜäöüß„“".chars() { + assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn german_ukrainian_italian_and_finnish_buttons_fit_the_bottom_bar_and_export_dialog() { + fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { + match shape { + egui::epaint::Shape::Text(shape) => { + bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size()))) + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)), + _ => {} + } + } + for language in [Locale::De, Locale::Uk, Locale::It, Locale::Fi] { + let ctx = fonts_ctx(&[]); + let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.language = language; + let mut bounds = Vec::new(); + for export in [false, true] { + if export { + app.run("dialog.export", serde_json::json!({})).unwrap(); + } + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + bounds.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut bounds); + } + } + let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1; + if export { + let dialog = rect("dialog:window"); + for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] { + assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id)); + } + } else { + let button = rect("button:copySettings"); + let (_, text) = bounds.iter().find(|entry| entry.0 == language.tr("Copy Edit Settings")).unwrap(); + assert!(button.contains_rect(*text), "{language:?} copy caption overflows its button: {text:?} vs {button:?}"); + assert!(!button.intersects(rect("icon:copyGear"))); + } + } + } + set_language(Locale::En); +} + +/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. +#[test] +fn menu_labels_translate_but_user_names_survive() { + for language in Locale::ALL { + set_language(*language); + assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); + } + set_language(Locale::En); +} + +/// The text the whole window paints over a few frames in `language`. +fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { + fn collect(shape: &egui::epaint::Shape, text: &mut String) { + match shape { + egui::epaint::Shape::Text(shape) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + app.ui.language = language; + let mut text = String::new(); + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + // This inspects shapes without a renderer; discard texture uploads explicitly. + out.textures_delta.clear(); + text.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut text); + } + } + text +} + +/// Every language is painted by the real window, switching at runtime reinstalls the fonts +/// (their CJK fallback order follows the language), and command ids never change with it. +#[test] +fn every_language_is_painted_and_menu_ids_stay_the_same() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let english_ids = ids(&app); + for language in Locale::ALL { + let text = painted_text(&ctx, &mut app, *language); + for label in ["My Photos", "All Photos"] { + assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); + } + assert_eq!(app.font_language, *language, "the fonts follow the language"); + assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); + } + set_language(Locale::En); +} + +/// The UI families, after one frame so the font definitions are loaded. +fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(craft)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx +} + +/// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI +/// family, Monospace included. +#[test] +fn craft_fonts_render_cjk_in_the_ui() { + let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; + for (language, sample) in samples { + if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + set_language(language); + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] { + let font = egui::FontId::new(13.0, family); + for ch in sample.chars() { + assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); + } + let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); + let wide = 13.0 * (sample.chars().count() as f32 - 1.0); + assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); + } + }); + } + set_language(Locale::En); +} + +/// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; +/// Latin text keeps Inter. +#[test] +fn the_ui_works_without_craft_fonts() { + let ctx = fonts_ctx(&[]); + ctx.fonts_mut(|fonts| { + // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's + // replacement-glyph face, which there is Hack, the face that draws Latin.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); + } + }); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + for language in Locale::ALL { + app.ui.language = *language; + for frame in 0..3 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + } + set_language(Locale::En); +} + +/// Traditional Chinese through the menu command and the control channel, persisted, with +/// localised headings; ids and user text are untouched. +#[test] +fn traditional_chinese_switches_through_menu_and_control_and_persists() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::ZhHant); + // Applied at once, not on the next frame. + assert_eq!(language(), Locale::ZhHant); + assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), Locale::ZhHant.tr("All Photos")); + assert_eq!(date_group_label("", false), Locale::ZhHant.tr("Unknown Date")); + assert_eq!(display_time("a-user-value"), "a-user-value"); + assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); + assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { + assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); + } + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), Locale::ZhHant.tr("Color")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant); + let ctx = egui::Context::default(); + app.ui.language = Locale::En; + for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] { + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], true, "{code}"); + assert_eq!(app.ui.language, Locale::ZhHant, "{code}"); + assert_eq!(language(), Locale::ZhHant, "{code} applies at once"); + } + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.ui.language, Locale::ZhHant); + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let chinese_ids = ids(&app); + app.run("app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(tr("File"), "File"); + assert_eq!(chinese_ids, ids(&app)); +} + +/// Dates follow every language's own patterns (English keeps the catalog's wording); a key that +/// is not a date never panics and never shows a raw pattern. +#[test] +fn date_headings_in_every_language() { + set_language(Locale::En); + assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026"); + assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); + assert_eq!(date_group_label("2026-09", true), "September"); + assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); + for language in Locale::ALL { + set_language(*language); + if *language != Locale::En { + let day = date_group_label("2026-09-20", false); + assert!(day.contains("20") && day.contains("2026") && day.contains(tr("Sunday")), "{language:?}: {day}"); + assert!(date_group_label("2026-09-20", true).contains("20"), "{language:?}"); + assert!(date_group_label("2026-09", false).contains("2026"), "{language:?}"); + assert!(date_group_label("2026", false).contains("2026"), "{language:?}"); + assert_eq!(display_time("2026-09-20T16:04:05"), format!("{day} 16:04:05"), "{language:?}"); + } + for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { + assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); + assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); + } + } + set_language(Locale::En); +} + +/// Stock presets and panel headers are translated; a user preset keeps its name, also when it +/// shares a stock preset's name. +#[test] +fn traditional_chinese_presets_and_panel_headers_are_painted() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.presets = true; + let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); + app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); + let zh = Locale::ZhHant; + for (panel, title) in [(crate::state::RightPanel::Activity, "History"), (crate::state::RightPanel::Versions, "Versions")] { + app.ui.right = panel; + let text = painted_text(&ctx, &mut app, zh); + assert!(text.contains(zh.tr(title)), "{text}"); + assert!(text.contains(zh.tr("Warm Glow")), "stock preset: {text}"); + assert!(text.contains("Warm Glow"), "user preset: {text}"); + assert!(text.contains(zh.tr("Color")), "stock group: {text}"); + } + assert_eq!(history_label("Exposure", &app.session.presets), zh.tr("Exposure")); + assert!(history_label("Preset: Warm Film", &app.session.presets).ends_with(zh.tr("Warm Film"))); + assert!(history_label("Preset: Warm Glow", &app.session.presets).ends_with("Warm Glow"), "the user's preset shares the name"); + set_language(Locale::En); +} + +#[test] +fn vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Vi); + assert!(text.contains(Locale::Vi.tr("My Photos")) && text.contains(Locale::Vi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Vi); + assert!(text.contains(Locale::Vi.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ĂăÂâÊêÔôƠơƯưĐđáàảãạéèẻẽẹíìỉĩịóòỏõọúùủũụýỳỷỹỵ".chars().chain( + Locale::Vi + .catalog() + .values() + .flat_map(|text| text.chars()) + .filter(|ch| ('\u{00c0}'..='\u{024f}').contains(ch) || ('\u{1ea0}'..='\u{1ef9}').contains(ch)), + ) { + assert!(fonts.has_glyph(&font, ch), "missing Vietnamese glyph {ch}"); + } + } + }); + set_language(Locale::En); +} +/// The messages each language lacks, which it shows in English. Never fails: `cargo xtask ci` +/// runs it with `--nocapture`, so the list lands in the gate log. Plain messages are every key +/// some catalog has plus the labels the UI passes to `tr` from tables (commands, controls, rule +/// fields…); formats are every key some `*-formats.json` has plus every `tr_format!` message in +/// the app's source. An empty translation counts as missing. +#[test] +fn translation_gaps_report() { + let notes = whats_new_lines(); + let messages: std::collections::BTreeSet = plain_messages().into_iter().filter(|m| !notes.contains(&m.as_str())).collect(); + let formats = format_messages(); + let quoted = |list: &[&str]| list.iter().map(|m| format!("{m:?}")).collect::>().join(", "); + let mut out = + format!("Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", messages.len(), formats.len(), notes.len()); + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + let catalog = language.catalog(); + let own_formats = formats_catalog(language.code()); + let missing: Vec<&str> = messages.iter().map(String::as_str).filter(|m| !catalog.contains_key(*m)).collect(); + let missing_formats: Vec<&str> = formats.iter().map(String::as_str).filter(|m| !own_formats.contains_key(*m)).collect(); + let missing_notes = notes.iter().filter(|line| !catalog.contains_key(**line)).count(); + out.push_str(&format!( + "{}: {} messages, {} formats, {} What's New lines\n", + language.code(), + missing.len(), + missing_formats.len(), + missing_notes + )); + if !missing.is_empty() { + out.push_str(&format!(" messages: {}\n", quoted(&missing))); + } + if !missing_formats.is_empty() { + out.push_str(&format!(" formats: {}\n", quoted(&missing_formats))); + } + } + println!("{out}"); +} + +/// Every plain message: the keys of every catalog (but contextual entries, which a language adds +/// only where it needs one) and the labels the UI translates from tables. +fn plain_messages() -> std::collections::BTreeSet { + use lightcraft_catalog::rules; + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in rules::FIELDS { + labels.push(label); + labels.extend(rules::ops_for(*kind).iter().map(|(_, label)| *label)); + if let rules::Kind::Choice(choices) = kind { + labels.extend(choices.iter().map(|(_, label)| *label)); + } + } + labels.extend(rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(rules::Issue::ALL.iter().map(|issue| issue.text())); + labels.extend([rules::bool_label(true), rules::bool_label(false)]); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + labels.extend(crate::date_picker::WEEKDAY_SHORT); + labels.extend(["Pick a date", "Year", "Month", "Day", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]); + // the album pickers + labels.extend([ + "Choose an album…", + "No albums yet", + "Search albums", + "No albums match", + "This is the album you're editing", + "It tests this album, so testing it back would loop", + "Fix the marked rules to save this album.", + "None", + "New album", + "New album…", + "Imported Photos", + ]); + labels.extend(crate::panels::keyword_list::VARIABLE_LABELS); + let contextual = format!("{BOOL_CONTEXT}|"); + Locale::ALL + .iter() + .flat_map(|language| language.catalog().keys()) + .filter(|key| !key.starts_with(&contextual)) + .cloned() + .chain(labels.into_iter().map(str::to_string)) + .collect() +} + +/// Help ▸ What's New, line by line as the dialog looks each one up. +fn whats_new_lines() -> Vec<&'static str> { + crate::panels::dialogs::WHATS_NEW + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("# ")) + .map(|line| line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line)) + .collect() +} + +/// `locales/-formats.json` (the catalog `build.rs` compiles into `tr_format!`), without +/// empty translations. +fn formats_catalog(code: &str) -> BTreeMap { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("locales/{code}-formats.json")); + let mut messages: BTreeMap = + std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default(); + messages.retain(|_, text| !text.is_empty()); + messages +} + +/// Every format message: the keys of every formats catalog and the message of every +/// `tr_format!` call in `src/` outside its test modules. +fn format_messages() -> std::collections::BTreeSet { + let mut all: std::collections::BTreeSet = + Locale::ALL.iter().filter(|language| **language != Locale::En).flat_map(|language| formats_catalog(language.code()).into_keys()).collect(); + let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + while let Some(dir) = dirs.pop() { + for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let source = std::fs::read_to_string(&path).unwrap(); + let app = test_module_start(&source).map_or(source.as_str(), |at| &source[..at]); + all.extend(tr_format_messages(app)); + } + } + } + all +} + +/// Where the first inline `#[cfg(test)] mod x { … }` starts. A `#[cfg(test)] mod x;` line only +/// declares a file, and app code can follow it. +fn test_module_start(source: &str) -> Option { + let head = "#[cfg(test)]\nmod "; + source + .match_indices(head) + .map(|(at, _)| at) + .find(|at| source[at + head.len()..].trim_start_matches(|c: char| c.is_alphanumeric() || c == '_').starts_with(" {")) +} + +/// The first argument of each `tr_format!` call in `source`, when it is a string literal. +fn tr_format_messages(source: &str) -> Vec { + // built from parts, so this file's own source holds no call to find + let call = concat!("tr_format", "!("); + let mut messages = Vec::new(); + for (at, _) in source.match_indices(call) { + let Some(body) = source[at + call.len()..].trim_start().strip_prefix('"') else { continue }; + let mut message = String::new(); + let mut chars = body.chars(); + while let Some(ch) = chars.next() { + match ch { + '"' => break, + '\\' => match chars.next() { + Some('n') => message.push('\n'), + Some('t') => message.push('\t'), + Some('u') => { + let hex: String = chars.by_ref().skip(1).take_while(|ch| *ch != '}').collect(); + message.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); + } + Some(other) => message.push(other), + None => break, + }, + _ => message.push(ch), + } + } + messages.push(message); + } + messages +} + +#[test] +fn tr_format_messages_are_read_from_source() { + let call = concat!("tr_format", "!("); + let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); + assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); +} + +/// A drop's notice comes from what `library.import` reported: a photo added, one brought back from Recently +/// Deleted and a file that could not be read are each named; a drop of duplicates only says nothing. +#[test] +fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_duplicates_only() { + set_language(Locale::En); + let dir = std::env::temp_dir().join(format!("lc-drop-notice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = |name: &str, seed: u8| { + let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(name), bytes).unwrap(); + dir.join(name).to_string_lossy().into_owned() + }; + let (trashed, added) = (png("trashed.png", 1), png("new.png", 2)); + let broken = dir.join("broken.jpg"); + std::fs::write(&broken, b"not a jpeg at all").unwrap(); + let mut s = lightcraft_engine::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [trashed]})).unwrap(); + s.execute("photo.delete", &serde_json::json!({"ids": [r["imported"][0]]})).unwrap(); + let report = s.execute("library.import", &serde_json::json!({"paths": [trashed, added, broken]})).unwrap(); + assert_eq!( + dropped_photos_outcome(&report).as_deref(), + Some("Added 1 photo · Restored 1 photo from Recently Deleted · 1 photo not readable"), + "{report}" + ); + let again = s.execute("library.import", &serde_json::json!({"paths": [added]})).unwrap(); + assert_eq!(dropped_photos_outcome(&again), None, "a duplicate only: {again}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Polish: the menu and control channel select it, dates read day.month.year, and every count +/// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so +/// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. +#[test] +fn polish_switches_formats_dates_and_reads_every_count() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.polish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Pl); + assert_eq!(Locale::Pl.name(), "Polski"); + assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); + assert_eq!(tr("File"), "Plik"); + assert_eq!(tr("Exposure"), "Ekspozycja"); + assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); + assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); + for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { + let s = if n == 1 { "" } else { "s" }; + assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); + for (one, many, noun) in [ + ("photo waiting", "photos waiting", "Oczekujące zdjęcia"), + ("cached picture", "cached pictures", "Obrazy w pamięci podręcznej"), + ("photo has its denoised picture.", "photos have their denoised picture.", "Zdjęcia z odszumioną wersją"), + ] { + assert_eq!(tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })), format!("{noun}: {n}")); + } + assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); + assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); + } + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); + let ctx = egui::Context::default(); + for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Pl); + } + let text = painted_text(&ctx, &mut app, Locale::Pl); + assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ„”".chars() { + assert!(fonts.has_glyph(&font, ch), "missing Polish glyph {ch}"); + } + } + }); + set_language(Locale::En); +} diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs deleted file mode 100644 index d62311c56..000000000 --- a/crates/ui-egui/src/import.rs +++ /dev/null @@ -1,1799 +0,0 @@ -//! The import review dialog (File → Import Photos… / Import from Folder… / Import from Device): -//! the source it scanned (a scanned folder is *not* added to Local), the files found under it as -//! a grid of thumbnails with checkboxes (duplicates marked and unchecked; Shift-click sets a -//! range), the destination (add in place / copy or move into the library's `Originals/` or a -//! chosen folder, filed by day, by month, into one folder or by a custom folder template, -//! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing -//! runs on a worker thread (files are probed, copied or moved there) and its batches join the -//! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is -//! one undo step. - -use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; -use lightcraft_engine::import::{ - ImportCandidate, ImportUprightMode, ImportWarning, ImportWarningCode, ScanInput, ScanOutput, ScanProgress, scan_with, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::sync::atomic::Ordering; - -use crate::LightcraftApp; -use crate::render::Slot; -use crate::theme::Tokens; -use crate::widgets::register; - -/// 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`]. -#[cfg(target_arch = "wasm32")] -const BATCH: usize = 8; - -/// 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]; -/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last -/// frame. Their difference is the room the grid leaves below it. -const GRID_BOTTOM: &str = "import-grid-bottom"; -const CONTENT_BOTTOM: &str = "import-content-bottom"; -/// Room below the grid before it has been measured (more than the options and buttons take). -const DEFAULT_BELOW_GRID: f32 = 360.0; - -/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, -/// in whole points so the window never grows by a fraction each frame, and at least one `row`. -pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { - let h = (available - below.max(0.0)).floor(); - if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) } -} - -/// Remember where the review dialog's content ends this frame (after its buttons), for -/// [`grid_height`] on the next. -pub(crate) fn note_dialog_bottom(ui: &egui::Ui) { - if !ui.is_sizing_pass() { - let y = ui.min_rect().bottom(); - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(CONTENT_BOTTOM), y)); - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ImportDialog { - /// The files / folders that were scanned (shown as the source). - pub sources: Vec, - pub candidates: Vec, - pub checked: Vec, - /// Copy into the library (else add in place). - pub copy: bool, - /// With `copy`: move instead — the originals are removed from the source once each copy is - /// verified and catalogued (`library.import` mode `move`). - pub move_files: bool, - /// Existing album to add to. - pub album: Option, - /// …or a new album with this name. - pub new_album: String, - /// Preset id ("" = none). - pub preset: String, - /// Comma-separated keywords. - pub keywords: String, - /// Copy: destination folder ("" = the library's Originals/). - pub destination: String, - /// Copy: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or `custom` ([`ImportDialog::folder_template`]). - pub organize: String, - /// Copy, `custom`: the folder template, e.g. `{date:%Y}/{date:%Y%m%d}`. - pub folder_template: String, - /// Copy: file-name template for the copies ("" = keep the names). - pub rename: String, - /// Metadata preset name ("" = none). - pub metadata_preset: String, - /// Copy: raws are copied as DNG. - pub dng: bool, - /// Per candidate: the library already has it in Recently Deleted ([`ImportDialog::set_trashed`]). - pub trashed: Vec, - /// What to do with those: `restore` (the default: the photo comes back with its edits), `fresh` - /// (imported as a new photo, the old record deleted) or "" (leave them) — the `onDeleted` param. - pub on_deleted: String, - /// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]). - #[serde(skip)] - pub last_clicked: Option, - /// The library the scan was made in ([`lightcraft_engine::Session::library_generation`]): its duplicate - /// decisions and album ids belong to that catalog, so the review is not shown or confirmed in another. - #[serde(skip)] - pub library: Option, -} - -/// A candidate's file type, as the review groups them: its format, or its extension when the -/// probe named none (`JPEG` and `JPG` are one type). -fn file_type(c: &ImportCandidate) -> String { - let f = if c.format.trim().is_empty() { - std::path::Path::new(&c.path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default() - } else { - c.format.clone() - }; - match f.trim().to_uppercase().as_str() { - "JPEG" => "JPG".into(), - "TIFF" => "TIF".into(), - "" => "?".into(), - other => other.to_string(), - } -} - -impl ImportDialog { - pub fn new(candidates: Vec) -> Self { - let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); - ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() } - } - - /// Whether the library the scan was made in is still the open one (a review that names none counts). - fn in_library(&self, app: &LightcraftApp) -> bool { - self.library.is_none_or(|g| g == app.session.library_generation()) - } - /// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice - /// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by - /// mistake comes back by importing its file again (issue #706). - pub fn set_trashed(&mut self, trashed: Vec) { - self.trashed = trashed; - for i in 0..self.candidates.len() { - if !self.is_trashed(i) { - continue; - } - let on = self.checked_by_default(i); - if let Some(c) = self.checked.get_mut(i) { - *c = on; - } - } - } - /// Candidate `i` can be checked: a readable file the library doesn't have, or one whose photo - /// is in Recently Deleted — even with "Leave them", a click picks it (issue #1285). - pub fn importable(&self, i: usize) -> bool { - self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i))) - } - /// Candidate `i` is checked by Check All and the file type toggles: with "Leave them", a file in - /// Recently Deleted is only checked by hand. - pub fn checked_by_default(&self, i: usize) -> bool { - self.importable(i) && !(self.is_trashed(i) && self.on_deleted.is_empty()) - } - /// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each. - pub fn file_types(&self) -> Vec<(String, usize)> { - let mut types: std::collections::BTreeMap = Default::default(); - for c in &self.candidates { - *types.entry(file_type(c)).or_default() += 1; - } - types.into_iter().collect() - } - /// Any file of type `ty` is checked. - pub fn type_checked(&self, ty: &str) -> bool { - self.candidates.iter().zip(&self.checked).any(|(c, on)| *on && file_type(c) == ty) - } - /// Check (the importable ones) or uncheck every file of type `ty`: the raws only, say, and not - /// the JPEGs kept beside them (issue #344). - pub fn check_type(&mut self, ty: &str, on: bool) { - for i in 0..self.candidates.len() { - let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty); - let importable = self.checked_by_default(i); - if same && let Some(c) = self.checked.get_mut(i) { - *c = on && importable; - } - } - } - /// Candidate `i` is a file whose photo is in Recently Deleted. - pub fn is_trashed(&self, i: usize) -> bool { - self.trashed.get(i).copied().unwrap_or(false) - } - /// Choose what happens to the files in Recently Deleted (`""`, `restore` or `fresh`); they are - /// checked once there is a choice and unchecked again without one. - pub fn set_on_deleted(&mut self, choice: &str) { - // switching between restore and fresh keeps the user's per-cell choices - let changed = self.on_deleted.is_empty() != choice.is_empty(); - self.on_deleted = choice.to_string(); - for i in (0..self.candidates.len()).filter(|_| changed) { - if self.is_trashed(i) - && let Some(c) = self.checked.get_mut(i) - { - *c = !choice.is_empty(); - } - } - } - /// A click on candidate `i` toggles its checkbox. With `shift`, every importable candidate - /// from the last clicked one to `i` takes `i`'s new state, as in a file manager: click the - /// first, Shift-click the last, and the whole range is checked (or unchecked). - pub fn click(&mut self, i: usize, shift: bool) { - if !self.importable(i) { - return; - } - let Some(on) = self.checked.get(i).map(|c| !c) else { return }; - let from = if shift { self.last_clicked.filter(|a| *a < self.checked.len()).unwrap_or(i) } else { i }; - for k in from.min(i)..=from.max(i) { - if self.importable(k) - && let Some(c) = self.checked.get_mut(k) - { - *c = on; - } - } - self.last_clicked = Some(i); - } - pub fn selected_paths(&self) -> Vec { - self.candidates - .iter() - .zip(&self.checked) - .enumerate() - .filter(|(i, (_, on))| **on && self.importable(*i)) - .map(|(_, (c, _))| c.path.clone()) - .collect() - } - /// The `organize` param of `library.import` (`None` = the default, by day); an unusable - /// custom folder template is an error. - pub fn organize_param(&self) -> Result, String> { - match self.organize.as_str() { - "" => Ok(None), - "custom" => { - let t = self.folder_template.trim(); - if let Some(e) = lightcraft_engine::rename::folder_template_error(t) { - return Err(e); - } - // a plain folder name ("Imports") is a one-level template too - Ok(Some(if t.contains(['{', '/', '\\']) { t.to_string() } else { format!("{t}/") })) - } - o => Ok(Some(o.to_string())), - } - } - fn keywords(&self) -> Vec { - self.keywords.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_string).collect() - } -} - -/// A running import (see the module docs). The file-system work — expanding folders, probing, -/// reading sidecars, copying or placing files — runs on a worker thread -/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the -/// UI thread between frames, so a slow drive never stalls the window. -#[derive(Debug, Default)] -pub struct ImportTask { - /// Files and folders to import (handed to the worker when the task starts). - queue: Vec, - pub total: usize, - pub done: usize, - params: Value, - pub imported: usize, - /// Brought back from Recently Deleted (`onDeleted: restore`). - pub restored: usize, - pub duplicates: usize, - /// The library photos the skipped duplicates match (shown when nothing new was added). - existing: Vec, - pub failed: usize, - /// Move: originals moved, and sources left in place (reported by the engine with a reason). - pub moved: usize, - pub kept: usize, - /// Structured diagnostics from foreign XMP develop settings. - pub warnings: Vec, - undo0: usize, - first: Option, - /// Reading a folder for the Local view: the photos stay out of the library, and nothing is - /// selected or announced as added. - browse: bool, - /// Cancel was pressed: no further files are started; batches already readied are added. - pub cancelled: bool, - /// Auto Import (the watched folder): the selection stays as it is, a short toast when done. - auto: bool, - /// Auto Import: the selection to keep. - keep_selection: Option, - /// The selection and source as the import last left them: a change between frames is the - /// user choosing another photo or view, which the end of the import doesn't take away. - left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>, - /// The user chose another photo or view while the import ran. - moved_on: bool, - run: Option, - /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). - guard: Option, -} - -impl ImportTask { - /// An import of `paths` (files or folders) with `library.import` params (without `paths`). - pub fn new(paths: Vec, params: Value, undo0: usize, browse: bool) -> Self { - ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() } - } - - /// An Auto Import (see [`ImportTask::auto`]). - pub fn auto(mut self) -> Self { - self.auto = true; - self - } - - /// `{done, total, imported, cancelled}` for `ui.inspect`. - pub fn status(&self) -> Value { - json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled}) - } -} - -/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no -/// slow drives) inline, one batch per frame. -enum Runner { - #[cfg(not(target_arch = "wasm32"))] - Thread(std::sync::mpsc::Receiver), - #[cfg(target_arch = "wasm32")] - Inline { job: Box, queue: Vec, files: Option> }, -} - -struct ImportRun { - runner: Runner, - cancel: std::sync::Arc, - total: std::sync::Arc, - done: std::sync::Arc, - opts: lightcraft_engine::import::ImportOptions, - now: String, - album: Option, - album_name: Option, -} - -impl std::fmt::Debug for ImportRun { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish() - } -} - -impl Drop for ImportRun { - /// A dropped import (replaced, or the app closing) stops its worker at the next file. - fn drop(&mut self) { - self.cancel.store(true, Ordering::Relaxed); - } -} - -impl ImportRun { - fn start( - job: lightcraft_engine::import::ImportJob, - queue: Vec, - album: Option, - album_name: Option, - ctx: &egui::Context, - ) -> Result { - let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let (total, done) = (job.total.clone(), job.done.clone()); - let (opts, now) = (job.opts.clone(), job.now().to_string()); - let runner = Self::spawn(job, queue, cancel.clone(), ctx)?; - Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name }) - } - - #[cfg(not(target_arch = "wasm32"))] - fn spawn( - mut job: lightcraft_engine::import::ImportJob, - queue: Vec, - cancel: std::sync::Arc, - ctx: &egui::Context, - ) -> Result { - let (tx, rx) = std::sync::mpsc::channel(); - let ctx = ctx.clone(); - let work = move || { - let files = job.expand(&queue); - for chunk in files.chunks(lightcraft_engine::import::batch_size()) { - if cancel.load(Ordering::Relaxed) { - break; - } - let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) { - Ok(p) => p, - Err(_) => break, // logged; the files readied so far are added - }; - if let Err(unsent) = tx.send(prepared) { - // the import was dropped: nothing will add these, so take back what a Move placed - unsent.0.rollback(); - break; - } - ctx.request_repaint(); - } - ctx.request_repaint(); - }; - std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}")) - } - - #[cfg(target_arch = "wasm32")] - fn spawn( - job: lightcraft_engine::import::ImportJob, - queue: Vec, - _cancel: std::sync::Arc, - _ctx: &egui::Context, - ) -> Result { - Ok(Runner::Inline { job: Box::new(job), queue, files: None }) - } - - /// Readied batches (at most a few per frame). `None` once the worker is done. - fn next_batches(&mut self) -> Option> { - match &mut self.runner { - #[cfg(not(target_arch = "wasm32"))] - Runner::Thread(rx) => { - let mut out = Vec::new(); - while out.len() < 4 { - match rx.try_recv() { - Ok(p) => out.push(p), - Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out), - Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) }, - } - } - Some(out) - } - #[cfg(target_arch = "wasm32")] - Runner::Inline { job, queue, files } => { - if self.cancel.load(Ordering::Relaxed) { - return None; - } - if files.is_none() { - *files = Some(job.expand(queue).into()); - return Some(Vec::new()); - } - let Some(files) = files.as_mut() else { return None }; - if files.is_empty() { - return None; - } - let n = files.len().min(BATCH); - let batch: Vec = files.drain(..n).collect(); - Some(vec![job.prepare_files(batch, &self.cancel)]) - } - } - } -} - -/// A folder scan running on a worker thread (a network share can take minutes to read; the -/// window must keep answering meanwhile). -pub struct ScanTask { - progress: std::sync::Arc, - rx: std::sync::mpsc::Receiver, - /// Open the review with "copy into the library" checked (a camera / card). - pub copy: bool, - /// Open the review with this album chosen (an album's context menu ▸ Import…). - pub album: Option, - /// Browsing a folder (Local): the photos are read in place instead of opening the review. - browse: bool, - /// What is being scanned (the review's source). - sources: Vec, - /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). - guard: lightcraft_engine::activity::TaskGuard, -} - -/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). -fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { - let label = if browse { "Reading folder" } else { "Scanning folder" }; - app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) -} - -/// An import and a Synchronize Folder never run at once: each readies its files against the -/// library as it was when it started, so both could add the same file. -pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { - if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } -} - -/// Settings → Import → "Show the import review when a camera or card is connected", called with -/// each device scan while it's on. The first look only notes the cards already in. A card that -/// shows up after that opens Import from Device once nothing else is open, and a card that is -/// taken out and put back in asks again. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec) { - let Some(known) = app.ui.known_cards.as_mut() else { - app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); - return; - }; - known.retain(|root| now.iter().any(|d| d.root == *root)); - let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; - // busy: ask when the window is free (the card isn't noted until then) - if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { - return; - } - known.push(card.root); - if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { - log::warn!("import from {}: {e}", card.name); - } -} - -/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). -pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result { - if app.scan.is_some() { - return Err("a scan is already running".into()); - } - if app.review_pending.is_some() { - return Err("an import review is waiting for the open dialog to close".into()); - } - let sources = paths.clone(); - let (input, paths) = ScanInput::new(&mut app.session, &paths); - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel(); - let p = progress.clone(); - let job = move || { - let _ = tx.send(scan_with(input, &paths, &p)); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(job); - #[cfg(target_arch = "wasm32")] - job(); - let guard = scan_guard(app, &progress, false); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard }); - Ok(json!({"scanning": true})) -} - -/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at -/// once, the folder is listed and read on a worker thread (a network share can take minutes), -/// and the photos then join the view in small batches under a progress window. A browse already -/// running is replaced; the import review's scan is not. -pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> Result { - let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?; - if !dir.is_dir() { - return Err(format!("{path}: not a folder")); - } - if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { - return Err("an import is running".into()); - } - let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); - let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); - let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse); - if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { - // already reading this folder: clicking it again must not restart the progress - app.session.source = lightcraft_engine::LibrarySource::Folder; - app.session.keep_active_in_view(); - return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); - } - if let Some(t) = app.scan.take() { - t.progress.cancel.store(true, Ordering::Relaxed); - } - app.import = None; - app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); - app.session.source = lightcraft_engine::LibrarySource::Folder; - // the photos the library already knows from this folder show at once: keep or pick the active one - app.session.keep_active_in_view(); - let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel(); - let p = progress.clone(); - let root = dir_s.clone(); - let job = move || { - let files: Vec = if subfolders { - lightcraft_engine::import::expand(&[root], None) - } else { - let mut v: Vec = std::fs::read_dir(&root) - .map(|rd| { - rd.flatten() - .map(|e| e.path()) - .filter(|f| { - f.is_file() - && lightcraft_engine::import::is_supported(f) - && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) - }) - .map(|f| f.to_string_lossy().to_string()) - .collect() - }) - .unwrap_or_default(); - v.sort(); - v - }; - let _ = tx.send(scan_with(input, &files, &p)); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(job); - #[cfg(target_arch = "wasm32")] - job(); - let guard = scan_guard(app, &progress, true); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); - app.renderer.forget_imports(); - Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) -} - -/// Collect a finished scan and open the review (called every frame). -pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { - show_pending_review(app, ctx); - let Some(task) = app.scan.as_ref() else { return }; - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - let out = match task.rx.try_recv() { - Ok(o) => o, - Err(std::sync::mpsc::TryRecvError::Empty) => { - if task.guard.is_cancelled() { - // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while) - app.scan = None; - return; - } - let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); - task.guard.progress(done as u64, total as u64); - // while the folders are listed the total isn't known: say what is happening instead - task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); - return; - } - Err(_) => { - app.scan = None; - app.toast(ctx, crate::i18n::tr("Scan failed")); - return; - } - }; - let Some(task) = app.scan.take() else { return }; - if task.progress.cancel.load(Ordering::Relaxed) { - return; - } - app.session.import_probes = out.probes; - if task.browse { - // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so - // their untouched Local photos aren't forgotten while the folder is in use - if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { - let shown: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); - app.session.note_browsed(&root, &shown); - } - // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, - // as `library.browse` forgets them (issue #1392) - if let Some(b) = app.session.browse.clone() { - let listed: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); - app.session.forget_gone_from(&b.path, b.subfolders, &listed); - } - // what the library doesn't know yet joins the Local view - let queue: Vec = - out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); - if !queue.is_empty() { - let undo0 = app.session.undo.len(); - let total = queue.len(); - let _ = total; - app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true)); - } - return; - } - if out.candidates.is_empty() { - app.toast(ctx, crate::i18n::tr("No photos found")); - return; - } - app.renderer.forget_imports(); - let mut d = ImportDialog::new(out.candidates); - let trashed = d - .candidates - .iter() - .map(|c| { - c.duplicate.is_some() - && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) - }) - .collect(); - d.set_trashed(trashed); - d.copy = task.copy; - // only an album the review offers (it may have been deleted while the scan ran) - d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); - d.sources = task.sources; - d.library = Some(app.session.library_generation()); - app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); - show_pending_review(app, ctx); -} - -/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… -/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was -/// closed meanwhile (Settings can open another) is dropped. -fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { - if app.review_pending.is_none() { - return; - } - if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending - && !opts.in_library(app) - { - app.review_pending = None; - return; - } - if app.ui.dialog.is_none() { - app.ui.dialog = app.review_pending.take(); - } else { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } -} - -/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. -#[cfg(test)] -pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box { - stall_scan_of(app, false) -} - -/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. -#[cfg(test)] -pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box { - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel::(); - let guard = scan_guard(app, &progress, false); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); - Box::new(tx) -} - -/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's -/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan -/// runs, and a browse that finishes replaces `app.import`. -pub fn running(app: &LightcraftApp) -> bool { - app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() -} - -impl ScanTask { - /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). - pub fn status(&self) -> Value { - json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)}) - } -} - -/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another -/// import runs: replacing `app.import` would stop its worker. -pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { - busy_synchronizing(app)?; - if !d.in_library(app) { - return Err("another library was opened since the photos were scanned: nothing was imported".into()); - } - // a browse scan that finishes starts its own import, replacing this one - if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { - return Err(crate::i18n::tr("An import is running").to_string()); - } - let queue = d.selected_paths(); - if queue.is_empty() { - return Err("no photos selected".into()); - } - let undo0 = app.session.undo.len(); - let mut album = d.album; - if album.is_none() && !d.new_album.trim().is_empty() { - let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?; - album = r["id"].as_u64(); - } - let mode = match (d.copy, d.move_files) { - (false, _) => "add", - (true, false) => "copy", - (true, true) => "move", - }; - let mut params = json!({"mode": mode, "keywords": d.keywords()}); - if !d.on_deleted.is_empty() { - params["onDeleted"] = json!(d.on_deleted); - } - if let Some(a) = album { - params["album"] = json!(a); - } - if !d.preset.is_empty() { - params["preset"] = json!(d.preset); - } - if !d.metadata_preset.is_empty() { - params["metadataPreset"] = json!(d.metadata_preset); - } - if d.copy { - if !d.destination.trim().is_empty() { - params["destination"] = json!(d.destination.trim()); - } - if let Some(o) = d.organize_param()? { - params["organize"] = json!(o); - } - if !d.rename.trim().is_empty() { - params["rename"] = json!(d.rename.trim()); - params["renameStart"] = json!(1); - } - if d.dng && !d.move_files { - params["dng"] = json!(true); - } - } - let total = queue.len(); - app.import = Some(ImportTask::new(queue, params, undo0, false)); - app.renderer.forget_imports(); - Ok(json!({"importing": total})) -} - -/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. -pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { - busy_synchronizing(app)?; - if running(app) { - return Err(crate::i18n::tr("An import is running").to_string()); - } - let undo0 = app.session.undo.len(); - let total = paths.len(); - app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false)); - Ok(json!({"importing": total})) -} - -/// Advance the import in progress (called every frame): start its worker, then add the batches it -/// has readied to the catalog. -pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(mut task) = app.import.take() else { return }; - if task.run.is_none() { - let mut p = task.params.clone(); - p["paths"] = json!(task.queue); - let started = lightcraft_engine::cmd::library::import_params(&app.session, &p) - .and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name))) - .map_err(|e| e.to_string()) - .and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx)); - if task.auto { - task.keep_selection = Some(app.session.selection.clone()); - } - match started { - Ok(run) => { - let label = if task.browse { "Reading folder" } else { "Importing" }; - task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); - task.run = Some(run); - } - Err(e) => { - log::warn!("import: {e}"); - app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); - return; - } - } - } - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - let Some(run) = task.run.as_mut() else { return }; - let batches = run.next_batches(); - let finished = batches.is_none(); - if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) { - task.moved_on = true; - } - for prepared in batches.into_iter().flatten() { - commit_batch(app, &mut task, prepared); - // arriving photos don't take over the selection - if let Some(sel) = &task.keep_selection { - app.session.selection = sel.clone(); - } - // a Local folder still on screen: its first photo becomes active once there is one (#1447) - if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { - app.session.keep_active_in_view(); - } - } - task.left = Some((app.session.selection.clone(), app.session.source)); - if let Some(run) = &task.run { - task.total = run.total.load(Ordering::Relaxed).max(task.total); - task.done = run.done.load(Ordering::Relaxed); - } - if let Some(guard) = &task.guard { - guard.progress(task.done as u64, task.total as u64); - // ✕ in the stack (or `activity.cancel`): no further files are started - if guard.is_cancelled() { - task.cancelled = true; - } - } - if !finished { - app.import = Some(task); - return; - } - finish(app, ctx, task); -} - -/// Add one readied batch to the catalog (an undo step merged into the import's at the end). -fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) { - let Some(run) = task.run.as_ref() else { return }; - let n = prepared.len(); - let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone()); - let r = app.session.execute_fn("library.import", |s| { - let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?; - lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref()) - }); - match r { - Ok(v) => { - // a new album (named in the params) is created with the first photos; later batches join it - if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) { - run.album = Some(a); - } - let len = |k: &str| v[k].as_array().map_or(0, Vec::len); - task.imported += len("imported"); - task.restored += len("restored"); - task.duplicates += len("duplicates"); - task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); - task.failed += len("failed"); - task.moved += len("moved"); - task.kept += len("kept"); - task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok())); - for k in v["kept"].as_array().into_iter().flatten() { - log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or("")); - } - if task.first.is_none() { - task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64)); - } - } - Err(e) => { - log::warn!("import: {e}"); - task.failed += n; - } - } -} - -fn xmp_warning_text(warning: &ImportWarning) -> String { - let path = &warning.path; - match warning.code { - ImportWarningCode::CameraProfile => crate::i18n::tr_format!( - "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.", - profile = warning.profile.as_deref().unwrap_or_default(), - path = path, - ), - ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) { - ImportUprightMode::Off => crate::i18n::tr_format!( - "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.", - path = path, - ), - ImportUprightMode::Guided => crate::i18n::tr_format!( - "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.", - path = path, - ), - ImportUprightMode::Auto => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Auto") - ), - ImportUprightMode::Level => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Level") - ), - ImportUprightMode::Vertical => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Vertical") - ), - ImportUprightMode::Full => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Full") - ), - ImportUprightMode::Unknown => { - crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,) - } - }, - ImportWarningCode::UnmappedXmp => { - crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),) - } - } -} - -fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { - crate::i18n::tr_format!( - "Import warnings ({n}):\n{warnings}", - n = warnings.len(), - warnings = warnings.iter().map(xmp_warning_text).collect::>().join("\n"), - ) -} - -/// 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" })); - } - return; - } - if let Some(f) = task.first.filter(|_| !task.moved_on) { - // a view the new photos aren't in (an album, Picks, a Local folder) would leave the active - // photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446) - if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) { - let _ = app.run("library.source", json!({"kind": "all"})); - } - 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 - && task.restored == 0 - && task.failed == 0 - && task.warnings.is_empty() - && !task.cancelled - && show_existing(app, ctx, &task.existing) - { - 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)) - } else { - crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) - }; - if task.restored > 0 && (task.imported > 0 || task.cancelled) { - msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); - } - 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))); - } - } - if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); - let deleted = deleted_of(app, &task.existing).len(); - if deleted > 0 { - msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); - } - } - 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); - } -} - -/// The photos among `ids` that are in Recently Deleted (each once). -fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { - let mut v: Vec = - ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); - v.sort_unstable(); - v.dedup(); - v -} - -/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel -/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. -fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { - let deleted = deleted_of(app, existing); - let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; - ids.sort_unstable(); - ids.dedup(); - ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); - if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { - return false; - } - let _ = app.run("library.select", json!({"ids": ids})); - app.ui.left_panel = true; - let n = ids.len(); - let plural = if n == 1 { "" } else { "s" }; - let msg = if kind == "all" { - crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) - } else { - crate::i18n::tr_format!( - "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", - n, - plural - ) - }; - app.toast_for(ctx, msg, 6.0); - true -} - -/// Skipped candidates: (already in the library, identical copies within the source, in Recently -/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with -/// the same bytes as one earlier in the list (no library photo) is a copy in the source. -fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { - let trashed = d.trashed.iter().filter(|t| **t).count(); - let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); - let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); - (dups, in_source, trashed) -} - -/// The dialog body: options, then the candidate grid. -pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { - let t = Tokens::get(ui.ctx()); - let n = d.candidates.len(); - let (dups, in_source, trashed) = dup_counts(d); - let sel = d.selected_paths().len(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); - if dups > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); - } - if in_source > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) - .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); - } - if trashed > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { - d.checked.iter_mut().for_each(|c| *c = false); - } - if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { - for i in 0..n { - d.checked[i] = d.checked_by_default(i); - } - } - }); - }); - // with more than one file type: a toggle per type checks or unchecks all of its files - let types = d.file_types(); - if types.len() > 1 { - ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim)); - for (ty, count) in &types { - let on = d.type_checked(ty); - let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on) - .on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" })); - if r.clicked() { - d.check_type(ty, !on); - } - } - }); - } - // candidate grid - let cell = 116.0; - let avail = ui.available_width(); - let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; - let rows = n.div_ceil(cols); - // as tall as the window leaves after the options and buttons below it (measured last frame), - // so resizing the dialog resizes the grid - let below = ui.ctx().data(|m| { - let y = |key: &str| m.get_temp::(egui::Id::new(key)); - y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) - }); - let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); - let grid = - egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { - let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); - for i in 0..n { - let (c, r) = (i % cols, i / cols); - let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0)); - if !local.intersects(viewport.expand(cell)) { - continue; - } - let rect = local.translate(area.min.to_vec2()); - candidate_cell(app, ui, d, i, rect); - } - }); - if !ui.is_sizing_pass() { - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(GRID_BOTTOM), grid.inner_rect.bottom())); - } - ui.add_space(4.0); - // options - if !d.sources.is_empty() { - field(ui, "Source", |ui| { - // (whether each source is a folder is checked off the UI thread, once) - let joined = d.sources.join("\n"); - let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| { - inspect_sources(&s.split('\n').map(str::to_string).collect::>()) - }) - .map(|summary| summary.display()) - .unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" })); - let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined); - register(ui.ctx(), "label:importSource", r.rect); - }); - ui.label( - egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")) - .color(t.text_dim) - .small(), - ); - } - field(ui, "Transfer", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy) - .on_hover_text(crate::i18n::tr("Reference the files where they are")) - .clicked() - { - d.copy = false; - d.move_files = false; - } - let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); - let r = - ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner; - if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() { - d.copy = true; - d.move_files = false; - } - let r = - ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner; - if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source")) - .clicked() - { - d.copy = true; - d.move_files = true; - } - }); - let r = ui.label( - egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) { - (true, false) => { - "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." - } - (true, true) => { - "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are." - } - _ => "Add in place: the library references the files where they are; nothing is copied or moved.", - })) - .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) - .small(), - ); - register(ui.ctx(), "label:importModeHelp", r.rect); - if d.copy { - field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() }; - ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) - { - d.destination = f; - } - if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() { - d.destination.clear(); - } - }); - field(ui, "Folders", |ui| { - let opts = [ - ("date", "By day (YYYY/YYYY-MM-DD)"), - ("month", "By month (YYYY/YYYY-MM)"), - ("flat", "Into one folder"), - ("custom", "Custom template…"), - ]; - let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; - let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); - let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (k, label) in opts { - let r = ui.selectable_label(key == k, crate::i18n::tr(label)); - register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect); - if r.clicked() { - d.organize = k.to_string(); - if k == "custom" && d.folder_template.trim().is_empty() { - d.folder_template = DEFAULT_FOLDER_TEMPLATE.into(); - } - } - } - }); - register(ui.ctx(), "combo:importOrganize", combo.response.rect); - }); - if d.organize == "custom" { - let folders_id = egui::Id::new("import-folder-template"); - let tags_open = field(ui, "Template", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = tag_field_width(ui); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w)); - register(ui.ctx(), "field:importFolderTemplate", r.rect); - tag_toggle(ui, "importFolders") - }); - if tags_open { - tag_help(ui, "importFolders", &mut d.folder_template, folders_id); - } - let t = Tokens::get(ui.ctx()); - match lightcraft_engine::rename::folder_template_error(&d.folder_template) { - Some(e) => { - ui.label(egui::RichText::new(e).color(t.caution)); - } - None => unknown_tags_warning(ui, &d.folder_template), - } - ui.label( - egui::RichText::new(crate::i18n::tr( - "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").", - )) - .color(t.text_dim) - .small(), - ); - } - if !d.move_files { - field(ui, "Raw files", |ui| { - let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG")); - register(ui.ctx(), "check:importDng", r.rect); - }); - } - let rename_id = egui::Id::new("import-rename"); - let tags_open = field(ui, "Rename", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = tag_field_width(ui); - let r = ui.add( - egui::TextEdit::singleline(&mut d.rename) - .id(rename_id) - .hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}")) - .desired_width(w), - ); - register(ui.ctx(), "field:importRename", r.rect); - tag_toggle(ui, "importRename") - }); - if tags_open { - tag_help(ui, "importRename", &mut d.rename, rename_id); - } - unknown_tags_warning(ui, &d.rename); - if let Some(example) = example_destination(app, d) { - let t = Tokens::get(ui.ctx()); - let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim)); - register(ui.ctx(), "label:importExample", r.rect); - ui.label( - egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date.")) - .color(t.text_dim) - .small(), - ); - } - } - if d.trashed.iter().any(|t| *t) { - field(ui, "In Recently Deleted", |ui| { - let cur = match d.on_deleted.as_str() { - "restore" => "Restore them", - "fresh" => "Import as new", - _ => "Leave them", - }; - let mut pick = None; - crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { - if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { - pick = Some(v); - } - } - }); - if let Some(v) = pick { - d.set_on_deleted(v); - } - let t = Tokens::get(ui.ctx()); - ui.label( - egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first.")) - .color(t.text_dim) - .small(), - ); - }); - } - let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); - albums.retain(|a| a.folder || !a.smart); - // Catalog changes while the review is open must not leave a hidden, unselectable destination. - d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); - field(ui, "Album", |ui| { - ui.vertical(|ui| { - let mut chosen = d.album; - let mut extra = None; - let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); - let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) - .width(ui.available_width()) - .empty_text(empty) - .show_with_choices(ui, |ui| { - let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); - register(ui.ctx(), "importAlbum:none", none.rect); - if none.clicked() { - extra = Some(false); - } - let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); - register(ui.ctx(), "importAlbum:new", new.rect); - if new.clicked() { - extra = Some(true); - } - ui.separator(); - extra.is_some() - }); - if response.changed() { - d.album = chosen; - d.new_album.clear(); - } - if let Some(new) = extra { - d.album = None; - if !new { - d.new_album.clear(); - } else if d.new_album.is_empty() { - d.new_album = crate::i18n::tr("Imported Photos").into(); - } - } - if d.album.is_none() && !d.new_album.is_empty() { - let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); - register(ui.ctx(), "field:importAlbumName", r.rect); - } - }); - }); - field(ui, "Preset", |ui| { - let cur = app - .session - .presets - .iter() - .find(|p| p.id == d.preset) - .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) - .unwrap_or_else(|| crate::i18n::tr("None").into()); - crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { - if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { - d.preset.clear(); - } - let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); - if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { - d.preset = pid; - } - }); - }); - if !app.session.metadata_presets.is_empty() { - field(ui, "Metadata", |ui| { - let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; - crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { - if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { - d.metadata_preset.clear(); - } - for m in &app.session.metadata_presets { - if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() { - d.metadata_preset = m.name.clone(); - } - } - }); - }); - } - field(ui, "Keywords", |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY)); - register(ui.ctx(), "field:importKeywords", r.rect); - }); -} - -fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { - let t = Tokens::get(ui.ctx()); - let c = d.candidates[i].clone(); - let ok = d.importable(i); - let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); - let resp = ui.interact(img, egui::Id::new(("import-cell", i)), Sense::click()); - crate::widgets::announce(&resp, c.name.clone()); - register(ui.ctx(), format!("import:{i}"), img); - let p = ui.painter(); - p.rect_filled(img, 3.0, t.skeleton); - // thumbnail (background job; the grid only asks for the cells in view) - let slot = Slot::Import(i as u32); - if !app.renderer.textures.contains_key(&slot) - && let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64) - { - app.renderer.request_quick(slot, job, 4); - } - if let Some(tex) = app.renderer.textures.get(&slot) { - let [tw, th] = tex.size; - let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32); - let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s)); - let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) }; - p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); - } - let on = d.checked[i] && ok; - if on { - p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } - // checkbox - let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); - p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); - if on { - p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); - p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); - } - let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); - let badge = match (&c.duplicate, &c.error) { - // checked with "Leave them", the file goes without `onDeleted`: the engine restores it - (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), - (Some(_), _) if in_trash => Some("In Recently Deleted"), - (Some(r), _) if r == "path" => Some("In library"), - (Some(_), _) => Some("Duplicate"), - (None, Some(_)) => Some("Unreadable"), - _ => None, - }; - if let Some(b) = badge { - let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); - let br = badge_rect(img, cb, g.size()); - p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); - p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); - } - let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::()) } else { c.name.clone() }; - p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim }); - let tip = format!( - "{}\n{} × {} · {} · {:.1} MB{}", - c.path, - c.width, - c.height, - c.format, - c.file_size as f64 / 1e6, - c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default() - ); - let resp = resp.on_hover_text(tip); - if resp.clicked() && ok { - let shift = ui.input(|input| input.modifiers.shift); - d.click(i, shift); - } -} - -/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit -/// beside it — drawn over the checkbox it hid it (issue #1285). -fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { - let size = text + vec2(8.0, 4.0); - let left = img.right() - size.x - 4.0; - let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; - Rect::from_min_size(pos2(left, top), size) -} - -/// Reserve the actual translated tag button width beside a template field. -pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text); - (ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0) -} - -/// The "Tags" toggle beside a template field (`button:Tags`); returns whether the tag help -/// ([`tag_help`]) is open. -pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { - let id = egui::Id::new((key, "tags-open")); - let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); - let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open) - .on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it")); - if r.clicked() { - open = !open; - ui.ctx().data_mut(|d| d.insert_temp(id, open)); - } - open -} - -/// "Unknown tag {x} stays as typed" under a template field, when it has one. -pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) { - let unknown = lightcraft_engine::rename::unknown_tokens(template); - if !unknown.is_empty() { - let s = if unknown.len() == 1 { "" } else { "s" }; - let text = crate::i18n::tr_format!( - "Unknown tag{s} {} stay{} as typed (see Tags)", - unknown.join(" "), - if unknown.len() == 1 { "s" } else { "" }, - s = s - ); - ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution)); - } -} - -/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection); -/// appended when the field never had a cursor. The cursor ends up after the tag. -pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) { - use egui::text::{CCursor, CCursorRange}; - let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default(); - let n = text.chars().count(); - let (a, b) = state.cursor.char_range().map_or((n, n), |r| { - let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n)); - (x.min(y), x.max(y)) - }); - let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); - let (ba, bb) = (byte(a), byte(b)); - text.replace_range(ba..bb, tag); - state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count())))); - state.store(ctx, edit_id); - ctx.memory_mut(|m| m.request_focus(edit_id)); -} - -/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its -/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag -/// (`button:Tag-`) inserts it at the cursor of the field `edit_id`. -pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { - use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; - let t = Tokens::get(ui.ctx()); - egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { - ui.label( - egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) - .color(t.text_dim) - .small(), - ); - egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { - egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { - for (i, tok) in TOKENS.iter().enumerate() { - let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( - if tok.aliases.is_empty() { - crate::i18n::tr("Insert at the cursor").to_string() - } else { - crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) - }, - ); - register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect); - if r.clicked() { - insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); - } - ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label)); - ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim)); - ui.end_row(); - } - }); - let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect(); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); - for n in TEMPLATE_NOTES { - ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small()); - } - }); - }); -} - -/// The folder template the Custom choice starts with (`2026/20260114/`). -pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}"; - -/// Where the first selected photo would be copied to (destination, folders, name), for the -/// dialog's example line; `None` without a photo or with an unusable folder template. -pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option { - let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?; - let organize = match d.organize_param().ok()? { - Some(o) => lightcraft_engine::import::Organize::parse(&o)?, - None => Default::default(), - }; - let mut q = lightcraft_catalog::Photo::new( - lightcraft_catalog::PhotoId(0), - lightcraft_catalog::Source::File { path: c.path.clone() }, - &c.name, - &c.format, - 0, - 0, - // a photo without a capture time is dated by the import (now) - &(app.session.clock)(), - ); - q.captured = c.captured.clone(); - // the probe's metadata, so {camera}, {title}… preview as they will import - if let Some(info) = app.session.import_probes.get(&c.path) { - q.meta = info.meta.clone(); - } - let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) }; - let root = if d.destination.trim().is_empty() { - app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string()) - } else { - d.destination.trim().trim_end_matches(['/', '\\']).to_string() - }; - let sep = std::path::MAIN_SEPARATOR_STR; - let mut parts = vec![root]; - parts.extend(organize.folders(&q)); - parts.push(name); - Some(parts.join(sep)) -} - -/// The import source in a few words: a folder's name, a file's name, or "N files and folders". -pub fn source_summary(sources: &[String]) -> String { - inspect_sources(sources).display() -} - -/// Facts cached by the filesystem worker, formatted in the UI thread's active language. -/// Caching a translated String on the worker would always use its default language (English) -/// and also retain that language after the user changes the UI language. -#[derive(Clone)] -enum SourceSummary { - Folder(String), - File(String), - Many { files: usize, folders: usize }, -} - -impl SourceSummary { - fn display(&self) -> String { - match self { - Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name), - Self::File(name) => name.clone(), - Self::Many { files, folders } => match (files, folders) { - (_, 0) => crate::i18n::tr_format!("{} files", files), - (0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f), - (_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f), - }, - } - } -} - -fn inspect_sources(sources: &[String]) -> SourceSummary { - let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string()); - match sources { - [one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)), - [one] => SourceSummary::File(name(one)), - many => { - let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); - SourceSummary::Many { files: many.len().saturating_sub(folders), folders } - } - } -} - -/// A labelled row (fixed label column). -fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(78.0); - ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); - }); - add(ui) - }) - .inner -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn grid_height_follows_the_window() { - // the grid takes what the options and buttons leave, in whole points - assert_eq!(grid_height(700.0, 300.0, 142.0), 400.0); - assert_eq!(grid_height(1000.5, 300.0, 142.0), 700.0); - // never less than one row of thumbnails - assert_eq!(grid_height(200.0, 300.0, 142.0), 142.0); - // a bad measurement doesn't take the grid away or make it endless - assert_eq!(grid_height(700.0, -50.0, 142.0), 700.0); - assert_eq!(grid_height(700.0, f32::NAN, 142.0), 700.0); - assert_eq!(grid_height(f32::INFINITY, 300.0, 142.0), DIALOG_SIZE[1] - DEFAULT_BELOW_GRID); - } - - /// Six candidates, all checked; candidate 3 is a duplicate (unchecked, not importable). - fn dialog() -> ImportDialog { - let mut candidates: Vec = (0..6).map(|i| ImportCandidate { path: format!("img{i}.jpg"), ..Default::default() }).collect(); - candidates[3].duplicate = Some("content".into()); - ImportDialog::new(candidates) - } - - /// Identical copies inside the source folder are not "already in the library" (a fresh library - /// said "8 already in the library"). - #[test] - fn copies_within_the_source_are_not_counted_as_in_the_library() { - let mut d = dialog(); - assert_eq!(dup_counts(&d), (0, 1, 0), "same bytes as an earlier file, no library photo"); - d.candidates[4].duplicate = Some("content".into()); - d.candidates[4].existing = Some(7); - d.candidates[5].duplicate = Some("path".into()); - d.candidates[5].existing = Some(8); - assert_eq!(dup_counts(&d), (2, 1, 0)); - } - - /// A toggle per file type: the raws only, without the JPEGs beside them (issue #344). - #[test] - fn file_types_check_and_uncheck_together() { - let mut candidates: Vec = - ["a.ARW", "a.JPG", "b.arw", "b.jpeg", "c.png"].iter().map(|p| ImportCandidate { path: p.to_string(), ..Default::default() }).collect(); - candidates[2].format = "ARW".into(); - candidates[3].format = "JPEG".into(); - candidates[2].duplicate = Some("content".into()); - let mut d = ImportDialog::new(candidates); - assert_eq!(d.file_types(), [("ARW".to_string(), 2), ("JPG".to_string(), 2), ("PNG".to_string(), 1)]); - assert!(d.type_checked("JPG")); - d.check_type("JPG", false); - assert_eq!(d.checked, [true, false, false, false, true]); - assert!(!d.type_checked("JPG")); - d.check_type("PNG", false); - d.check_type("ARW", true); - // the duplicate stays unchecked: it isn't importable - assert_eq!(d.checked, [true, false, false, false, false]); - d.check_type("JPG", true); - assert_eq!(d.checked, [true, true, false, true, false]); - assert_eq!(d.selected_paths(), ["a.ARW", "a.JPG", "b.jpeg"]); - // an unknown type changes nothing - d.check_type("CR3", false); - assert_eq!(d.checked, [true, true, false, true, false]); - } - - #[test] - fn click_toggles_one_candidate() { - let mut d = dialog(); - d.click(1, false); - assert_eq!(d.checked, [true, false, true, false, true, true]); - d.click(1, false); - assert_eq!(d.checked, [true, true, true, false, true, true]); - d.click(3, false); - assert_eq!(d.checked, [true, true, true, false, true, true], "a duplicate can't be checked"); - } - - #[test] - fn shift_click_sets_the_range_to_the_clicked_state() { - let mut d = dialog(); - d.click(1, false); - d.click(4, true); - assert_eq!(d.checked, [true, false, false, false, false, true], "1..=4 unchecked, the duplicate left alone"); - assert_eq!(d.selected_paths(), ["img0.jpg", "img5.jpg"]); - // backwards from the new anchor (4) re-checks 2..=4 - d.click(2, true); - assert_eq!(d.checked, [true, false, true, false, true, true]); - } - - #[test] - fn shift_click_without_an_earlier_click_toggles_one() { - let mut d = dialog(); - d.click(2, true); - assert_eq!(d.checked, [true, true, false, false, true, true]); - } - - /// With "Leave them", a file in Recently Deleted stays out of Check All and the type toggles, - /// but a click still checks it: it is imported (restored) on its own (issue #1285). - #[test] - fn leave_them_keeps_a_trashed_file_out_until_it_is_clicked() { - let mut d = dialog(); - d.set_trashed(vec![false, false, false, true, false, false]); - assert!(d.checked[3], "restored by default"); - d.set_on_deleted(""); - assert!(!d.checked[3]); - d.check_type("JPG", true); - assert!(!d.checked[3], "the type toggle leaves it out"); - d.click(3, false); - assert!(d.checked[3], "a click checks it"); - assert!(d.selected_paths().contains(&"img3.jpg".to_string())); - d.click(3, false); - assert!(!d.checked[3]); - // a plain duplicate (not in Recently Deleted) still can't be checked - d.set_trashed(vec![false; 6]); - d.click(3, false); - assert!(!d.checked[3]); - } - - /// A badge too wide to fit beside the checkbox goes under it instead of covering it (issue #1285). - #[test] - fn a_badge_never_covers_the_checkbox() { - let img = Rect::from_min_size(pos2(100.0, 50.0), vec2(116.0, 116.0)); - let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); - // "Will be restored" fits beside it, top right - let short = badge_rect(img, cb, vec2(76.0, 12.0)); - assert_eq!(short.right(), img.right() - 4.0); - assert_eq!(short.top(), img.top() + 6.0); - // "In Recently Deleted" doesn't - let long = badge_rect(img, cb, vec2(96.0, 12.0)); - assert!(!long.intersects(cb), "{long:?} covers {cb:?}"); - assert_eq!(long.right(), img.right() - 4.0); - assert!(img.contains_rect(long)); - } - - #[test] - fn shift_click_with_a_stale_anchor_toggles_one() { - let mut d = dialog(); - d.last_clicked = Some(99); - d.click(2, true); - assert_eq!(d.checked, [true, true, false, false, true, true]); - } -} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 026c5866d..9db7035c1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,14 +6,11 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -pub mod album_picker; pub mod brush_live; -pub mod button; pub mod canvas_keys; mod command_error; pub mod control; -pub mod credits; -pub mod date_picker; +pub mod dialogs; pub mod docking; mod drop_overlay; pub mod export_task; @@ -22,199 +19,32 @@ mod frame_rate; pub mod headless; pub mod i18n; pub mod icons; -pub mod import; -pub mod lightroom_destination; pub mod lightroom_import; pub mod links; -pub mod menu_level; -pub mod menubar; pub mod menus; -pub mod merge; mod model_setup; pub mod panels; pub mod pick; -pub mod print_view; -pub mod progress; -pub mod rating; pub mod recheck; pub mod region; pub mod reload; pub mod render; pub mod shortcuts; pub mod sidebar; -pub mod slideshow_export; pub mod softpaint; pub mod state; pub mod sync; pub mod tasks; -pub mod text_field; pub mod theme; -pub mod titlebar; pub mod widgets; pub mod window_place; #[cfg(test)] -mod tests_activity; +mod tests; #[cfg(test)] -mod tests_album_picker; +use tests::panels as tests_panels; #[cfg(test)] -mod tests_album_remove; -#[cfg(test)] -mod tests_appearance; -#[cfg(test)] -mod tests_before_after; -#[cfg(test)] -mod tests_browse_active; -#[cfg(test)] -mod tests_button; -#[cfg(test)] -mod tests_canvas_keys; -#[cfg(test)] -mod tests_canvas_pan; -#[cfg(test)] -mod tests_capture_time; -#[cfg(test)] -mod tests_compare; -#[cfg(test)] -mod tests_copy_paste_dialogs; -#[cfg(test)] -mod tests_crop_rotate; -#[cfg(test)] -mod tests_crop_tool; -#[cfg(test)] -mod tests_curve; -#[cfg(test)] -mod tests_date_picker; -#[cfg(test)] -mod tests_dialog_fit; -#[cfg(test)] -mod tests_dialog_primary; -#[cfg(test)] -mod tests_dialog_settings; -#[cfg(test)] -mod tests_dialog_tab; -#[cfg(test)] -mod tests_dialogs; -#[cfg(test)] -mod tests_drop_import; -#[cfg(test)] -mod tests_edit_layout; -#[cfg(test)] -mod tests_empty_library; -#[cfg(test)] -mod tests_error_kinds; -#[cfg(test)] -mod tests_filmstrip; -#[cfg(test)] -mod tests_focus_point; -#[cfg(test)] -mod tests_folder_drop; -#[cfg(test)] -mod tests_grading; -#[cfg(test)] -mod tests_grid; -#[cfg(test)] -mod tests_guided_guides; -#[cfg(test)] -mod tests_histogram_drag; -#[cfg(test)] -mod tests_info_dialogs; -#[cfg(test)] -mod tests_keymap; -#[cfg(test)] -mod tests_keyword_files; -#[cfg(test)] -mod tests_keyword_list; -#[cfg(test)] -mod tests_keyword_set; -#[cfg(test)] -mod tests_keyword_sidebar; -#[cfg(test)] -mod tests_keywording; -#[cfg(test)] -mod tests_labels; -#[cfg(test)] -mod tests_launch_library; -#[cfg(test)] -mod tests_layout_image; -#[cfg(test)] -mod tests_library_problem; -#[cfg(test)] -mod tests_lightroom_destination; -#[cfg(test)] -mod tests_lights_out; -#[cfg(test)] -mod tests_mask_effects; -#[cfg(test)] -mod tests_masking; -#[cfg(test)] -mod tests_masking_layout; -#[cfg(test)] -mod tests_menubar; -#[cfg(test)] -mod tests_merge_preview; -#[cfg(test)] -mod tests_offline; -#[cfg(test)] -mod tests_open_lightroom; -#[cfg(test)] -mod tests_panels; -#[cfg(test)] -mod tests_preset_favorites; -#[cfg(test)] -mod tests_preset_import; -#[cfg(test)] -mod tests_preview_limit; -#[cfg(test)] -mod tests_print; -#[cfg(test)] -mod tests_profile_menu; -#[cfg(test)] -mod tests_profiles_layout; -#[cfg(test)] -mod tests_progress; -#[cfg(test)] -mod tests_quit_unsaved; -#[cfg(test)] -mod tests_rating; -#[cfg(test)] -mod tests_recheck; -#[cfg(test)] -mod tests_rename_person; -#[cfg(test)] -mod tests_rules_editor; -#[cfg(test)] -mod tests_sam_setup; -#[cfg(test)] -mod tests_scroll; -#[cfg(test)] -mod tests_slider_reset; -#[cfg(test)] -mod tests_slideshow; -#[cfg(test)] -mod tests_solo_sections; -#[cfg(test)] -mod tests_switch_library; -#[cfg(test)] -mod tests_sync; -#[cfg(test)] -mod tests_tallies; -#[cfg(test)] -mod tests_temp_slider; -#[cfg(test)] -mod tests_titlebar; -#[cfg(test)] -mod tests_tool_overlay; -#[cfg(test)] -mod tests_unsaved; -#[cfg(test)] -mod tests_view_cost; -#[cfg(test)] -mod tests_wb_selector; -#[cfg(test)] -mod tests_zoom; -#[cfg(test)] -mod tests_zoom_keys; +use tests::{fixtures as tests_fixtures, render_fixtures as tests_render_fixtures}; use std::sync::mpsc::{Receiver, Sender}; @@ -223,120 +53,46 @@ use serde_json::Value; pub use command_error::CommandError; pub use control::{ControlRequest, ControlResponse}; +pub use dialogs::credits; +pub use dialogs::import; +pub use dialogs::lightroom_destination; +pub use dialogs::merge; +pub use dialogs::print_view; +pub use dialogs::slideshow_export; +pub use dialogs::updates_ui; +pub use menus::menu_level; +pub use menus::menubar; pub use state::UiState; +pub use widgets::album_picker; +pub use widgets::button; +pub use widgets::date_picker; +pub use widgets::progress; +pub use widgets::rating; +pub use widgets::text_field; + +mod app; +mod caches; +mod clock; +mod services; + +#[cfg(test)] +use app::{import_tick_allowed, sam_download_ended, shot_ready}; +pub(crate) use caches::key_of; +pub use caches::{FilterValues, LibraryCounts, RulesView}; +use clock::AppClock; +pub use clock::{monotonic_ms, now_ms}; +#[cfg(target_os = "linux")] +pub use services::OpenModelFolderFn; +pub use services::{ + CheckEditorFn, HostAction, OpenAllWithFn, OpenUrlFn, OpenWithFn, PickFiles, PickFolder, PngEncode, RevealFn, SaveFile, ScreenProfileFn, Services, + SharedWrite, SystemThemeFn, WriteFn, +}; +use state::{GestureFrame, InjectedPress}; const TOAST_SECONDS: f64 = 1.4; /// The `path` of a display profile read from the screen rather than chosen as a file. pub const SCREEN_PROFILE: &str = "screen"; -pub type PickFiles = Box Vec>; -/// A save dialog: suggested file name → chosen path (`None` = cancelled). -pub type SaveFile = Box Option>; -pub type WriteFn = Box Result<(), String>>; -/// A writer other threads can use (background export). -pub type SharedWrite = std::sync::Arc Result<(), String> + Send + Sync>; -pub type PngEncode = Box Vec>; -/// A folder chooser (`None` = cancelled). -pub type PickFolder = Box Option>; -/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux). -pub type RevealFn = Box Result<(), String>>; -/// Open a URL in the user's browser. -pub type OpenUrlFn = Box Result<(), String>>; -/// Open a file in an application (`app` = "" for the system's default one). -pub type OpenWithFn = Box Result<(), String>>; -/// Open several files with one start of an application (`app` as for [`OpenWithFn`]). -pub type OpenAllWithFn = Box Result<(), String>>; -/// Linux model-folder work: wait for the default opener on a background task. -#[cfg(target_os = "linux")] -pub type OpenModelFolderFn = std::sync::Arc Result<(), String> + Send + Sync>; -/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: -/// not known). -pub type ScreenProfileFn = Box Option>>; -/// Whether an external editor can be started (its name as in [`Services::open_with`]); the -/// error says why not and what to do (e.g. PhotoCraft isn't installed). -pub type CheckEditorFn = Box Result<(), String>>; -/// Something the host does with the session (web: back up or restore the library in browser -/// storage); the work may finish asynchronously. -pub type HostAction = Box Result>; - -/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose -/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it -/// last heard and wakes the UI itself when that changes; it is never polled for changes. -pub type SystemThemeFn = Box Option>; - -/// Platform services injected by the host app (desktop or web). -#[derive(Default)] -pub struct Services { - /// Native file dialogs for commands, shown off the UI thread and answered later - /// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside - /// dialogs; without it (the web, tests) they answer the commands too. - pub picker: Option, - /// Show an open dialog for photos; returns paths. - pub pick_files: Option, - /// Local ONNX model; a manifest must accompany it. - pub pick_denoise_model: Option, - /// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…). - pub pick_model_file: Option, - /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). - pub pick_preset_files: Option, - /// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…). - pub pick_tracklog: Option, - /// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only). - pub pick_display_profile: Option, - /// The profile of the screen showing the window, used while none is chosen in Settings ▸ - /// Display (macOS, whose windows aren't colour-managed: issue #787). - pub screen_profile: Option, - /// Save dialog for an exported `.lcpreset` file. - pub save_preset_file: Option, - /// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…). - pub pick_keyword_list: Option, - /// Save dialog for an exported keyword list (File ▸ Export Keywords…). - pub save_keyword_list: Option, - /// Open dialog for point-curve preset files (`.lccurve`). - pub pick_curve_preset_files: Option, - /// Save dialog for an exported `.lccurve` file. - pub save_curve_preset_file: Option, - /// Open dialog for slideshow template files (`.json`; Settings ▸ Slideshow ▸ Templates ▸ Import…). - pub pick_slideshow_templates: Option, - /// Save dialog for an exported slideshow template (`.json`). - pub save_slideshow_template: Option, - pub write: Option, - /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). - pub write_shared: Option, - /// PNG encoder (the host links an image encoder; the UI crate stays codec-free). - pub png: Option, - /// Show a file in the system file manager (desktop only). - pub reveal: Option, - /// The host's current log file (`/logs/lightcraft.log`), which Help ▸ Open Log - /// Folder reveals (#260). None where no log is kept: the web, `--memory`, `LIGHTCRAFT_NO_PREFS`. - pub log_file: Option, - /// Choose a folder (Settings → General → Open Library…; desktop only). - pub pick_folder: Option, - /// Open a Lightroom Classic `.lrcat` catalog for read-only import. - pub pick_lightroom_catalog: Option, - /// Open a web link in the browser (Help menu, About, Discord button). - pub open_url: Option, - /// Open a file in an external editor (Edit in External Editor; desktop only). - pub open_with: Option, - /// Open several files with one start of the editor (Open Original; desktop only): an editor - /// that keeps one window takes them all. None = [`Services::open_with`] for each. - pub open_all_with: Option, - /// Optional worker-safe model-folder opener alongside `open_with` (Linux desktop only). - #[cfg(target_os = "linux")] - pub open_model_folder: Option, - /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = - /// assume it can). - pub check_editor: Option, - /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the - /// user keeps (web only: there the library lives in browser storage, which the browser may - /// clear; on the desktop it is a folder backed up like any other). - pub backup_library: Option, - /// File ▸ Restore Library from Backup… (web only; keeps the current library). - pub restore_library: Option, - /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. - pub system_theme: Option, -} - #[derive(Clone, Copy, Debug, Default)] pub struct Perf { /// Layout of the last frame ([`LightcraftApp::ui`], including commands run from widgets). @@ -384,6 +140,8 @@ pub struct LightcraftApp { pub perf: Perf, /// macOS: the host draws the traffic lights over our top bar. pub integrated_titlebar: bool, + /// The `movable` flag last given to the window (macOS, `craft_window::title_bar::sync_movable`). + pub window_movable: Option, /// The host installed a native menu bar (no in-window menus then). pub native_menu: bool, /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler @@ -399,17 +157,65 @@ pub struct LightcraftApp { pub headless_host: bool, /// Warnings to show one at a time (damaged settings files…, issue #103). pub notices: Vec, + pub(crate) updates_rt: updates_ui::Runtime, /// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running. pub quit_prompt: Option, + /// Keyboard/focus/input state for the prompt above, including its one-frame creation barrier. + pub(crate) notice_input: panels::notices::PromptInput, /// Quit Anyway was chosen: the window may close with unsaved changes. pub quit_confirmed: bool, control_rx: Option>, pending_screenshots: Vec, + /// Synthetic-input control requests waiting for a fresh layout and delivery (plan §4.5). + pending_gestures: Vec, + /// Completed, nondiscarded UI layouts. A gesture admitted before this advanced may resolve. + layout_epoch: u64, screenshot_token: u64, + /// Bytes reserved by admitted, not-yet-drained screenshots (in-flight and completed alike). + shot_bytes: usize, + /// A geometry change since the last logic call: a completed image from an older epoch is + /// stale even when the window has meanwhile been resized back to the issued size. + shot_geometry_epoch: u64, + last_shot_geometry: Option, + /// The texture limit the host last reported (`RawInput::max_texture_side`), retained across + /// passes where the host reports it only once. + max_texture_side: usize, + /// Monotonic app clock (`web_time::Instant`), not the wall clock and not egui's per-pass time: + /// timers must keep advancing while the window is hidden. + clock: AppClock, + /// The language already sent to `Context::set_language`, so a logic pass does not allocate a + /// `String` just to compare it. + accesskit_language: Option, + /// When playback was auto-paused because the window became hidden (`None`: it is running, or + /// the user paused it). On unhide the pause is lifted and the slide keeps its remaining time. + slideshow_hidden_at: Option, + /// The app (not the user) paused the slideshow because the window was hidden. + slideshow_auto_paused: bool, + /// Files dropped since the last UI pass, consumed exactly once by the UI phase. + dropped_files: Vec, + /// Prefix of `dropped_files` that arrived behind a prompt, library window or its picker. + /// Preserve only this prefix through subsequent dialogs/imports; later arrivals still take + /// the ordinary refusal path unless they too arrive while the original barrier is up. + deferred_dropped_file_count: usize, + /// A synthetic batch was injected into a pass and has not been consumed by a completed + /// (nondiscarded) UI pass yet: a hidden logic tick must not inject another one. + synthetic_in_flight: bool, + /// Completed, nondiscarded root UI passes; host frames, not layout passes. + ui_passes: u64, + /// Test-only: the library barrier's per-pass decisions, so a first-return test can observe the + /// dedicated shield/discard pass and the following background pass rather than only its input + /// effects (which the global shortcut guard also hides). + #[cfg(test)] + pub(crate) library_passes: Vec, /// Offscreen context for headless screenshots of the windowed app. shadow: Option, /// Synthetic input events (from the control channel) injected one step per frame. pub synthetic: Vec, + /// A gesture was canceled after its synthetic pointer press reached egui. The next + /// [`Self::raw_input_hook`] cancels that press before egui's next input pass, without a + /// `PointerMoved` (which can start a drag) or a `PointerButton` release (which can become a + /// click). + pointer_cleanup: bool, /// Files the control channel (`ui.dropFiles`) holds over the window: every frame reports them /// as hovered, as the OS does while a drag is over the window. drop_hover: Vec, @@ -419,10 +225,15 @@ pub struct LightcraftApp { pub copied: Option, /// Native file dialogs up for commands (`pick`): each command runs again when its closes. pub(crate) pending_picks: Vec, - /// Modifiers announced for synthetic input (held from a button down to its release). + /// Modifiers announced for synthetic input (held from a button down to its release). This is + /// only the app's synthetic ownership; the real host state is tracked separately in + /// `host_mods`, so a canceled synthetic gesture never clears a physically held key. synthetic_mods: egui::Modifiers, /// Clear `synthetic_mods` on the next frame. synthetic_mods_release: bool, + /// The host's last reported physical modifier state (`ModifiersChanged`), restored after a + /// synthetic gesture releases instead of unconditionally announcing `NONE`. + host_mods: egui::Modifiers, styled: bool, /// The language the installed fonts were built for: the CJK fallback order follows the UI /// language's script, so switching language reinstalls them. @@ -438,8 +249,14 @@ pub struct LightcraftApp { /// drawn last (`ui.inspect` → `scroll`). pub grid_scroll: Option, pub film_scroll: Option, - /// Widget registry from the last frame (automation ids → rects). + /// Widget registry from the last completed, nondiscarded frame (automation ids → rects). pub widgets: Vec<(String, egui::Rect)>, + /// Egui hit owners for [`Self::widgets`] from the same completed pass. A press is delivered + /// only when this id still owns egui's pointer hit (see `control::widget_target_visible`). + pub widget_hits: Vec<(String, widgets::WidgetHit)>, + /// Registry collected by the pass being laid out, published only if that pass completes. + next_widgets: Vec<(String, egui::Rect)>, + next_widget_hits: Vec<(String, widgets::WidgetHit)>, /// In-progress on-canvas gesture (brush stroke points, gradient drag…). pub gesture: Option, /// The brush stroke being painted, as drawn on the photo until it's committed (issue #517). @@ -519,6 +336,8 @@ pub struct LightcraftApp { /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, + /// First-frame focus/input state for a newly shown blocking library window. + pub(crate) library_window_input: panels::library_problem::WindowBarrier, /// Alt/Option held as the window first appears offers the recent libraries once (issue /// #1290); the desktop host arms it when nothing chose the library already. pub alt_asks_for_library: bool, @@ -529,1125 +348,154 @@ pub struct LightcraftApp { pub activity_expanded: bool, } -impl LightcraftApp { - pub fn new(mut session: Session, services: Services) -> Self { - // AI mask requests run on the model's worker; frames apply their results (never wait) - session.segmenter.background = true; - Self { - session, - ui: UiState::default(), - services, - renderer: render::Renderer::default(), - perf: Perf::default(), - caches: Caches::default(), - integrated_titlebar: false, - native_menu: false, - native_shortcuts: Default::default(), - recording_shortcut: None, - text_focus: false, - headless_host: false, - notices: vec![], - quit_prompt: None, - quit_confirmed: false, - control_rx: None, - pending_screenshots: vec![], - screenshot_token: 0, - shadow: None, - synthetic: vec![], - drop_hover: vec![], - drop_pending: vec![], - copied: None, - pending_picks: vec![], - synthetic_mods: egui::Modifiers::NONE, - synthetic_mods_release: false, - styled: false, - font_language: i18n::Locale::En, - chinese_font: None, - fonts_ready: false, - last_time: 0.0, - canvas_rect: None, - image_rect: None, - grid_scroll: None, - film_scroll: None, - widgets: vec![], - gesture: None, - brush_live: None, - loupe_shown: None, - region_view: None, - region_before_view: None, - size_hold: Default::default(), - drag_scale: Default::default(), - window_refused: None, - region_tiles: Default::default(), - compare_windows: [None; 2], - compare_tiles: Default::default(), - compare_hold_until: f64::NEG_INFINITY, - merge: merge::MergeState::default(), - import: None, - scan: None, - sync: None, - sync_owns_changes: false, - sync_run: None, - lightroom: None, - lightroom_last: None, - lightroom_result: None, - review_pending: None, - shown_library: None, - export: None, - tasks: Default::default(), - last_export_result: None, - slideshow_export_reported: true, - hover_preview: None, - window_is_fullscreen: false, - gpu_applied: None, - gpu_notice: false, - memory_applied: None, - display_applied: None, - display_error: None, - screen_profile: None, - screen_profile_read: None, - library_problem: None, - alt_asks_for_library: false, - recent_checks: Default::default(), - activity_expanded: false, - model_setup: Default::default(), - } - } - - /// Supply the browser's separately downloaded Simplified Chinese font before the first frame. - pub fn with_chinese_font(mut self, bytes: Vec) -> Self { - if !bytes.is_empty() { - self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes))); - } - self - } - - pub fn with_control(mut self, rx: Receiver) -> Self { - self.control_rx = Some(rx); - // keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them - self.renderer.keep_pixels = true; - self - } - - /// The open person page, the way back to it, and the photo it was opened from follow a rename. - fn follow_renamed_person(&mut self, from: &str, to: &str) { - let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); - if self.ui.person_page.as_deref().is_some_and(same) { - self.ui.person_page = Some(to.to_string()); - } - if self.ui.last_person.as_deref().is_some_and(same) { - self.ui.last_person = Some(to.to_string()); - } - if let Some((Some(name), photo)) = self.ui.person_from.clone() - && same(&name) - { - self.ui.person_from = Some((Some(to.to_string()), photo)); - } - } - - /// Run a UI or engine command by id. The single entry point for every frontend path. - pub fn run(&mut self, id: &str, params: Value) -> Result { - self.run_typed(id, params).map_err(|e| e.message) - } - - /// [`LightcraftApp::run`], keeping what kind of error a refusal is: for code that decides on it - /// (offer a model's download, treat a cancel as one) instead of reading the message. - pub fn run_typed(&mut self, id: &str, params: Value) -> Result { - if let Some(result) = model_setup::intercept(self, id, ¶ms) { - return result.map_err(CommandError::from); - } - if let Some(r) = menus::run_ui_command(self, id, ¶ms) { - if r.is_ok() { - docking::reveal_command(self, id)?; - } - return r.map_err(CommandError::from); - } - let params = if id == "crop.aspect" - && params.get("aspect").and_then(Value::as_str) == Some("toggle") - && self.crop_tool_aspect().is_some() - && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - { - serde_json::json!({"aspect": "free"}) - } else { - params - }; - let r = self.session.execute(id, ¶ms).map_err(CommandError::from); - if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") { - self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") { - Some(serde_json::json!("original")) - } else { - self.session - .active() - .and_then(|photo| self.session.develop_of(photo)) - .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) - }; - } - if r.is_ok() && id == "library.toggleFilters" { - // the search field shows the text filter: empty while the filters are off, back with them - self.ui.search = self.session.filter.text.clone(); - } - if r.is_ok() && id == "library.clearFilter" { - // the search field shows the text filter: cleared from anywhere (the loupe's pill, the - // menu), it must not keep a search that no longer applies (issue #1648) - self.ui.search.clear(); - } - if r.is_ok() && id == "mask.adjust" { - // Judge local adjustments on the photo, without the selection overlay obscuring them. - // Keep it hidden after release; O / the overlay eye can show it again. - self.ui.mask_overlay = false; - } - if r.is_ok() && matches!(id, "mask.add" | "mask.addComponent" | "mask.duplicate" | "mask.select") { - // A new or newly picked mask shows its overlay again, as in Lightroom: hidden by a slider - // drag above, it stayed hidden while the Overlay menu still named a mode (issue #1800). - self.ui.mask_overlay = true; - } - if r.is_ok() { - // These commands finish synchronously, so their completion would otherwise - // be invisible once the activity stack is gone. Never show success on error. - let completed = match id { - "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), - "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Discard Preview Cache"))), - "develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))), - _ => None, - }; - if let Some(message) = completed { - // Menu clicks can arrive before the next frame updates last_time. - let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time); - self.ui.toast = Some((message, now + TOAST_SECONDS, None)); - } - } - if r.is_ok() && id == "photo.label" { - let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); - let text = match label { - Some(label) => { - let name = - self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into()); - i18n::tr_format!("{name} Label", name = name) - } - None => i18n::tr("Color label cleared").into(), - }; - // Native menu clicks can arrive before logic() updates last_time after an idle gap. - let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time); - self.ui.toast = Some((text, now + TOAST_SECONDS, label)); - } - match &r { - Err(e) => { - log::warn!("{id}: {e}"); - self.ui.status = e.message.clone(); - // no detector yet: offer it (its terms first) instead of only saying so - if id == "faces.detect" && e.kind == ErrorKind::ModelMissing(AiModel::FaceDetector) { - panels::faces::offer_detector(self); - } - } - Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v), - Ok(v) if id == "faces.renamePerson" => { - let to = v["to"].as_str().unwrap_or("").to_string(); - let from = params.get("from").and_then(Value::as_str).unwrap_or(""); - if !to.is_empty() { - self.follow_renamed_person(from, &to); - } - } - Ok(_) => {} - } - r - } - - /// The remembered ratio belongs to the tool until the user starts editing the crop. - pub(crate) fn crop_tool_aspect(&self) -> Option<(u32, u32)> { - let photo = self.session.active()?; - let d = self.session.develop_of(photo)?; - if d.crop != lightcraft_develop::Crop::default() { - return d.crop.aspect; - } - // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one - // Lock toggle (remembered as "free") unlocks it for good - let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); - match &remembered { - Value::String(s) if s == "original" => { - let p = self.session.catalog.photo(photo)?; - let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); - Some(if d.orientation.swaps_axes() { (h, w) } else { (w, h) }) - } - Value::Array(a) => { - let number = |i: usize| { - let v = a.get(i)?.as_f64()? * 100.0; - (v.is_finite() && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v.round() as u32) - }; - Some((number(0)?, number(1)?)) - } - _ => None, - } - } - - /// Called only after a crop gesture begins, inside its undo transaction. - pub(crate) fn begin_crop(&mut self) -> Result<(), String> { - // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it - let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); - if self.session.interaction.is_some() - && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - && aspect != serde_json::json!("free") - // the remembered ratio, turned like the new photo's crop (as a named ratio is) - && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) - { - // A remembered value the command refuses (a hand-edited settings file) would refuse - // every crop drag on an untouched photo: forget it and let this drag go ahead. - self.ui.crop_default_aspect = None; - self.ui.status = e.to_string(); - } - Ok(()) - } - - /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). - /// Advance a running slideshow. - fn slideshow_tick(&mut self, ctx: &egui::Context) { - panels::slideshow::tick(self, ctx); - } - - /// Announce the end of a Build Previews run. - fn preview_build_status(&mut self, ctx: &egui::Context) { - use std::sync::atomic::Ordering; - let Some(b) = self.session.preview_build.clone() else { return }; - let key = std::sync::Arc::as_ptr(&b) as usize; - if b.finished.load(Ordering::Relaxed) { - if self.ui.preview_build_seen != Some((key, true)) { - self.ui.preview_build_seen = Some((key, true)); - let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed)); - let plural = if done == 1 { "" } else { "s" }; - let mut msg = match (b.error(), b.what) { - (Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }), - (None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural), - (None, what) => { - crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what)) - } - }; - if failed > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed)); - } - self.toast(ctx, msg); - } - } else { - // The activity stack shows the run while it works; poll for the end. - ctx.request_repaint_after(std::time::Duration::from_millis(250)); - } - } - - /// Announce when saving the library starts failing (changes then live only in memory and are - /// retried) and when it works again; the top bar's cloud icon shows the state meanwhile. - fn save_status(&mut self, ctx: &egui::Context) { - let unsaved = self.session.unsaved().map(|(n, e)| (n, e.to_string())); - match (unsaved, self.ui.unsaved_seen) { - (Some((n, e)), false) => { - self.ui.unsaved_seen = true; - let t = ctx.input(|i| i.time); - self.ui.toast = Some(( - crate::i18n::tr_format!( - "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry", - if n == 1 { "" } else { "s" }, - n = n, - e = e - ), - t + 6.0, - None, - )); - } - (None, true) => { - self.ui.unsaved_seen = false; - self.toast(ctx, crate::i18n::tr("Library saved")); - } - (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry - (None, false) => {} - } - } - - pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { - self.toast_for(ctx, text, TOAST_SECONDS); - } - - /// Auto straighten (the crop panel's Auto, a double-click with the Straighten tool), saying so - /// when it finds nothing to level by instead of leaving the photo as it is without a word. - pub fn auto_straighten(&mut self, ctx: &egui::Context) { - match self.run("crop.autoStraighten", serde_json::json!({})) { - Ok(r) if r.get("changed").and_then(Value::as_bool) == Some(false) => { - self.toast(ctx, crate::i18n::tr("No horizon or vertical lines to level by")); - } - Ok(_) => {} - Err(e) => self.toast(ctx, e), - } - } - - /// A toast that stays `secs` seconds (messages that say where to look or what to do next). - pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into, secs: f64) { - let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + secs, None)); - } - - /// Reverse geocoding: say how a place-names download ended (a failure stays readable, with what to do). - fn place_names_outcome(&mut self, ctx: &egui::Context) { - use lightcraft_engine::cmd::geocode::DownloadOutcome; - match self.session.place_names.take_outcome() { - Some(DownloadOutcome::Done(_)) => self.toast(ctx, crate::i18n::tr("Place names downloaded")), - Some(DownloadOutcome::Cancelled) => self.toast(ctx, crate::i18n::tr("Place names download cancelled")), - Some(DownloadOutcome::Failed(e)) => self.toast_error(ctx, e), - None if self.session.place_names.downloading() => ctx.request_repaint_after(std::time::Duration::from_millis(500)), - None => {} - } - } - - /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and - /// show their errors; start a zoomed-in detail pass once the clicking stops - /// (`ui.detail_due`); watch the model download. Never waits for the model. - fn ai_mask_detail(&mut self, ctx: &egui::Context) { - let polled = self.session.segment_poll(); - let now = ctx.input(|i| i.time); - if polled.changed { - ctx.request_repaint(); - } - if let Some(mask) = polled.refine { - // a new selection from a click or a description: show it, as `run` does for new masks - self.ui.mask_overlay = true; - self.ui.detail_due = Some((now + 0.3, mask)); - } - if let Some(e) = polled.messages.into_iter().last() { - self.ai_error(ctx, e, None); - } - let seg = &self.session.segmenter; - if seg.busy() || seg.pending_clicks().is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(50)); - } - let download = seg.download_status(); - if download.running { - ctx.request_repaint_after(std::time::Duration::from_millis(250)); - } - // a download finishing while its dialog is closed: say so once - if self.ui.sam_downloading && !download.running { - self.ui.sam_downloading = false; - let open = matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })); - match sam_download_ended(&download) { - _ if open => {} - Some((text, true)) => self.toast_error(ctx, text), - Some((text, false)) => self.toast(ctx, text), - None => {} - } - } - if let Some((due, mask)) = self.ui.detail_due { - if now >= due && !self.session.segmenter.busy() && !self.session.segmenter.detail_busy() { - self.ui.detail_due = None; - if let Err(e) = self.run("mask.refineDetail", serde_json::json!({"id": mask})) { - log::warn!("detail pass: {e}"); - } - } else { - ctx.request_repaint_after(std::time::Duration::from_millis(150)); - } - } - if self.session.segmenter.detail_busy() { - ctx.request_repaint_after(std::time::Duration::from_millis(200)); - } - } - - /// An AI mask error: when the model isn't installed, the dialog that offers to download it - /// (`then`: the AI mask to start afterwards); otherwise a toast. Decided on the error's kind - /// ([`ErrorKind::ModelMissing`]), not its words. - pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into, then: Option<(&str, &str)>) { - let e = e.into(); - if lightcraft_engine::segment::Segmenter::AVAILABLE && e.kind == ErrorKind::ModelMissing(AiModel::Sam3) { - self.offer_sam_download(then); - } else { - self.toast_error(ctx, e.message); - } - } - - /// Open the dialog offering the SAM 3 download (never downloads by itself). - pub fn offer_sam_download(&mut self, then: Option<(&str, &str)>) { - if self.ui.dialog.is_none() || matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })) { - self.ui.dialog = Some(state::Dialog::SamModel { then: then.map(|(k, o)| (k.to_string(), o.to_string())), error: None }); - } - } - - /// A toast for an error the user has to read and act on (stays 6 s). - pub fn toast_error(&mut self, ctx: &egui::Context, text: impl Into) { - let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + 6.0, None)); - } - - fn drain_control(&mut self, ctx: &egui::Context) { - let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { - // a request its sender gave up on (the window drew nothing for a while) is not run - // late: it would happen twice once the sender retries (issue #718) - if !req.take() { - continue; - } - let reply = req.reply.clone(); - match control::handle(self, ctx, &req) { - control::Outcome::Done(v) => { - let _ = reply.send(v); - } - control::Outcome::Screenshot { path, headless } => { - self.screenshot_token += 1; - let now = now_ms(); - self.pending_screenshots.push(PendingShot { - token: self.screenshot_token, - path, - reply, - headless: headless && !self.headless_host, - not_before: now + 120.0, - deadline: now + SCREENSHOT_SETTLE_MS, - frames: 0, - sent_at: None, - }); - } - } - } - self.control_rx = Some(rx); - } - - /// Advance pending screenshots: wait (≥ 3 frames, ≥ 120 ms) until no renders are in flight - /// (or a timeout), then capture — via the host's compositor, or headlessly - /// ([`Self::headless_screenshot`]) when asked or when the compositor delivers nothing. - fn issue_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let now = now_ms(); - let busy = self.renderer.in_flight() > 0 || self.merge.busy(); - let mut shots = std::mem::take(&mut self.pending_screenshots); - let mut shadow_ticked = false; - shots.retain_mut(|s| { - if let Some(sent) = s.sent_at { - if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host { - return true; - } - // The compositor delivered nothing (display asleep, window occluded): go headless. - log::warn!("ui.screenshot: no frame from the compositor after {SCREENSHOT_FALLBACK_MS} ms; rendering headlessly"); - s.sent_at = None; - s.headless = true; - s.deadline = now + SCREENSHOT_SETTLE_MS; - } - s.frames += 1; - let ready = now >= s.not_before && s.frames >= 3 && (!busy || now > s.deadline); - if s.headless { - // The shadow frame requests the renders the UI needs, even while the window shows nothing. - let img = if ready || !shadow_ticked { self.headless_screenshot(ctx, ready) } else { None }; - shadow_ticked = true; - if let Some(img) = img { - let _ = s.reply.send(control::save_screenshot(self, &img, s.path.as_deref())); - return false; - } - true - } else { - if ready { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token))); - s.sent_at = Some(now); - } - true - } - }); - shots.append(&mut self.pending_screenshots); - self.pending_screenshots = shots; - if !self.pending_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - /// The system's appearance: the host's answer first, then egui's (none: unknown). - pub fn system_theme(&self, ctx: &egui::Context) -> Option { - self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) - } - - /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light - /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. - pub fn sync_theme(&mut self, ctx: &egui::Context) { - let wanted = self.ui.settings.theme(self.system_theme(ctx)); - if theme::current(ctx) != Some(wanted) { - theme::apply_kind(ctx, wanted); - } - } - - /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it - /// on the CPU (photo textures come from the renderer's CPU copies). - pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { - let mut view = self.shadow.take().unwrap_or_default(); - let size = main.input(|i| i.content_rect()).size(); - let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) }; - // Feed native viewport points; the shadow context applies the copied UI zoom. - let zoom = main.zoom_factor(); - let size = size * zoom; - let ppp = main.pixels_per_point() / zoom; - let time = main.input(|i| i.time); - if view.frames() == 0 { - // warm-up pass: activates our fonts (pending font definitions live in `Memory`, which - // is replaced below) - view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |_| {}); - } - // same scroll offsets, open sections, style… as the window - let memory = main.memory(|m| m.clone()); - view.ctx.memory_mut(|m| *m = memory); - view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.ui(ui)); - let img = capture.then(|| { - let mut tex = self.renderer.cpu_textures(); - if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { - tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); - } - view.paint(&tex) - }); - self.shadow = Some(view); - img - } - - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) - } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_screenshots.iter().position(|s| s.token == token) { - let s = self.pending_screenshots.remove(i); - let _ = s.reply.send(control::save_screenshot(self, &image, s.path.as_deref())); - } - } - } - - /// The process is about to end (settings and library are saved by then): make sure nothing is - /// inside the GPU driver when it does (issue #620: a render running on a worker thread while - /// the process exits crashes in the driver). No GPU work starts from here on; the render - /// workers get up to `timeout` to finish their jobs, and what is left of it goes to GPU work - /// on other threads (export, denoise, the device warm-up). `false`: the deadline passed first - /// (the host exits anyway). The app renders nothing afterwards. - pub fn shutdown(&mut self, timeout: std::time::Duration) -> bool { - lightcraft_engine::gpu::begin_shutdown(); - #[cfg(not(target_arch = "wasm32"))] - let t0 = std::time::Instant::now(); - let stopped = self.renderer.shutdown(timeout); - #[cfg(not(target_arch = "wasm32"))] - let timeout = timeout.saturating_sub(t0.elapsed()); - let idle = lightcraft_engine::gpu::wait_idle(timeout); - stopped && idle - } - - /// Per-frame logic before layout (control channel, renders, shortcuts, drops). - pub fn logic(&mut self, ctx: &egui::Context) { - i18n::set_language(self.ui.language); - let t0 = now_ms(); - panels::library_problem::logic(self, ctx); - self.logic_inner(ctx); - self.perf.logic_ms = now_ms() - t0; - } - - fn logic_inner(&mut self, ctx: &egui::Context) { - if !self.styled { - theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); - theme::apply(ctx); - // File → Add from Device lists cards scanned in the background: show hot-plugs - let repaint = ctx.clone(); - lightcraft_engine::devices::on_change(move || repaint.request_repaint()); - // "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a - // worker fills: a sleeping NAS or a dropped share never stalls a frame - let repaint = ctx.clone(); - self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); - self.styled = true; - self.font_language = self.ui.language; - } else if self.font_language != self.ui.language { - // Shared Han characters take the active language's forms (Japanese faces for 日本語, - // the Simplified Chinese face for 简体中文): rebuild the fallback order. - theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); - self.font_language = self.ui.language; - } else { - self.fonts_ready = true; - } - self.sync_theme(ctx); - panels::notices::logic(self); - // closing the window (or Quit) with changes only in memory: retry, else ask first - if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - } - // (once a frame: a pass drawn again after a discard has no input of its own, and still the - // widgets of the pass thrown away, where a request would find them out of place) - if ctx.current_pass_index() == 0 { - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = now; - self.drain_control(ctx); - } - self.apply_settings(ctx); - if !self.synthetic.is_empty() { - ctx.request_repaint(); - } - self.renderer.poll(ctx, &mut self.session); - merge::poll(self, ctx); - import::poll_scan(self, ctx); - sync::poll(self, ctx); - sync::poll_run(self, ctx); - import::tick(self, ctx); - lightroom_import::tick(self, ctx); - recheck::tick(self); - tasks::poll(self, ctx); - self.preview_build_status(ctx); - self.save_status(ctx); - self.slideshow_tick(ctx); - panels::faces::pump(self, ctx); - // back from an external editor: pick up the files it saved - let focused = ctx.input(|i| i.focused); - if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() { - let ids = self.ui.external_edits.clone(); - if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) - && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) - { - self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); - } - } - self.ui.was_focused = focused; - // auto import: list the watched folder every few seconds (on a worker thread: it may be on - // a network share), then import what's new like any import (also on a worker thread) - #[cfg(not(target_arch = "wasm32"))] - if let Some(folder) = self.session.import_defaults.auto_folder.clone() { - const LABEL: &str = "Auto Import"; - let now = ctx.input(|i| i.time); - if now - self.ui.auto_import_at >= 3.0 - && self.import.is_none() - && self.sync_run.is_none() - && self.lightroom.is_none() - && !self.tasks.is_running(LABEL) - { - self.ui.auto_import_at = now; - let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); - let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { - let listing = match listing { - Ok(l) => l, - Err(e) => return log::debug!("auto import: {e}"), - }; - let p = serde_json::json!({"listing": listing, "start": false}); - let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return }; - let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return }; - let paths: Vec = - params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); - if let Some(o) = params.as_object_mut() { - o.remove("paths"); - } - let undo0 = app.session.undo.len(); - app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); - }; - // listed every few seconds: quiet, no row in the activity stack - if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { - log::warn!("{e}"); - } - } - ctx.request_repaint_after(std::time::Duration::from_secs(3)); - } - // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files - // are reachable, and said once when they start waiting and once when they're written - #[cfg(not(target_arch = "wasm32"))] - { - if self.session.unsaved_sidecars() > 0 { - let now = ctx.input(|i| i.time); - if now - self.ui.sidecar_retry_at >= 10.0 { - self.ui.sidecar_retry_at = now; - self.session.retry_unsaved_sidecars(); - } - ctx.request_repaint_after(std::time::Duration::from_secs(10)); - } - let waiting = self.session.unsaved_sidecars(); - if waiting > 0 && self.ui.sidecars_waiting == 0 { - self.toast_for( - ctx, - crate::i18n::tr_format!( - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - n = waiting - ), - 8.0, - ); - } else if waiting == 0 && self.ui.sidecars_waiting > 0 { - self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); - } - self.ui.sidecars_waiting = waiting; - } - // Settings → Import: open the import review when a camera or card is connected - #[cfg(not(target_arch = "wasm32"))] - if self.session.import_defaults.show_on_card { - if let Some(now) = lightcraft_engine::devices::devices_scanned() { - import::cards_seen(self, now); - } - ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); - } else { - self.ui.known_cards = None; - } - self.session.persist_if_dirty(); - panels::denoise::pump(self, ctx); - model_setup::pump(self, ctx); - panels::faces::pump(self, ctx); - focus_scan::pump(self, ctx); - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - if self.fonts_ready { - shortcuts::handle(self, ctx); - } - #[cfg(not(target_arch = "wasm32"))] - self.handle_dropped_files(ctx); - } - - /// Files dropped on the window: face models open their licence dialog, presets import as - /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). - #[cfg(not(target_arch = "wasm32"))] - fn handle_dropped_files(&mut self, ctx: &egui::Context) { - let dropped: Vec = - ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect()); - if dropped.is_empty() { - return; - } - // a dialog is on screen: the review would replace it, losing what it holds - if self.ui.dialog.is_some() { - self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); - return; - } - let drop = drop_overlay::classify(dropped); - if let Some(model) = drop.models.first() - && let Err(e) = panels::faces::open_dialog(self, model) - { - self.toast(ctx, e); - } - if !drop.presets.is_empty() { - // presets live in a library: with none open they would go with the session it replaces - if panels::library_problem::holds_drops(self) { - self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); - } else { - let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); - } - } - if drop.photos.is_empty() { - return; - } - // no library open yet (the launch list, a library that failed to open): they wait for one - let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; - if self.lightroom.is_some() { - self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); - return; - } - if import::running(self) { - self.toast(ctx, crate::i18n::tr("An import is running")); - return; - } - let started = if self.ui.settings.import_dropped_directly { - // read and added on a worker thread (dropped folders can be large, or on a slow drive) - import::start_paths(self, photos).map(|_| ()) - } else { - import::open(self, photos).map(|_| ()) - }; - if let Err(e) = started { - self.toast(ctx, e); - } - } - - /// Apply settings that act outside the UI state: GPU rendering and window full screen. - fn apply_settings(&mut self, ctx: &egui::Context) { - if self.gpu_applied != Some(self.ui.settings.gpu) { - self.gpu_applied = Some(self.ui.settings.gpu); - let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); - // GPU device + kernels off the UI thread, once the window is up and only when GPU - // rendering is on: a broken driver must not keep the window from appearing (issue #136) - if self.ui.settings.gpu { - lightcraft_engine::gpu::warm_up(); - } - } - let mb = self.ui.settings.memory_mb; - // automatic at startup: leave the engine's default alone - if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { - let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; - let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); - } - self.memory_applied = Some(mb); - // the monitor profile (Settings ▸ Display): previews follow it from the next frame - if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) { - let path = self.ui.settings.display_profile.clone(); - match lightcraft_engine::display::Display::load_opt(&path) { - Ok(d) => { - self.renderer.set_display(d); - self.display_error = None; - } - Err(msg) => { - // a profile that can't be used: previews are sRGB, the setting is kept - self.renderer.set_display(None); - log::warn!("display profile: {msg}"); - self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used"))); - self.display_error = Some(msg); - } - } - self.display_applied = Some(path); - // none chosen again: the screen's profile is read afresh - self.screen_profile = None; - self.screen_profile_read = None; - } - if self.ui.settings.display_profile.is_empty() { - self.follow_screen_profile(ctx); - } - if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { - self.window_is_fullscreen = fs; - } - if let Some(on) = self.ui.window_fullscreen.take() { - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); - self.window_is_fullscreen = on; - } - } - - /// While no display profile is chosen, show previews through the profile of the screen the - /// window is on (`services.screen_profile`), read about once a second so moving the window to - /// another screen is followed. Without the service (or a profile) the display is sRGB. - fn follow_screen_profile(&mut self, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); - if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { - return; - } - self.screen_profile_read = Some(now); - self.apply_screen_profile(); - } - - /// Read the screen's profile now and show previews through it (sRGB without one). - pub(crate) fn apply_screen_profile(&mut self) { - let Some(read) = self.services.screen_profile.as_mut() else { return }; - let icc = read(); - if icc == self.screen_profile { - return; - } - let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { - Ok(d) => Some(std::sync::Arc::new(d)), - Err(msg) => { - // a profile we can't use: previews stay sRGB, as before #787 - log::warn!("screen profile: {msg}"); - None - } - }); - self.renderer.set_display(display); - self.screen_profile = icc; - } - - /// Inject synthetic events (pointer events one per frame; key sequences up to the release). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - // the frame after a synthetic release / key: modifiers back up - if std::mem::take(&mut self.synthetic_mods_release) && self.synthetic_mods != egui::Modifiers::NONE { - self.synthetic_mods = egui::Modifiers::NONE; - raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); - } - let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); - if !self.drop_pending.is_empty() { - raw.dropped_files = std::mem::take(&mut self.drop_pending) - .into_iter() - .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) - .collect(); - } - if !self.drop_hover.is_empty() { - raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); - } - if self.synthetic.is_empty() { - return; - } - let n = match self.synthetic[0] { - egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - // egui's `input.modifiers` follow `ModifiersChanged` events, not the modifiers carried by - // pointer/key events: announce the injected events' modifiers (held from button down to - // the frame after up), so ⌥-drag, ⇧-click … work through the control channel - let carried = self.synthetic[..n].iter().find_map(|e| match e { - egui::Event::PointerButton { pressed, modifiers, .. } => Some((*modifiers, !pressed)), - egui::Event::Key { modifiers, .. } => Some((*modifiers, true)), - _ => None, - }); - if let Some((m, ends)) = carried { - if m != self.synthetic_mods { - self.synthetic_mods = m; - raw.events.push(egui::Event::ModifiersChanged(m)); - } - self.synthetic_mods_release = ends; - } - raw.events.extend(self.synthetic.drain(..n)); - } - - /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / - /// Survey area) in black. Painted above the panels, below dialogs and notices. - fn lights_out(&self, ctx: &egui::Context) { - let a = self.ui.lights_out.veil(); - if a <= 0.0 { - return; - } - let screen = ctx.content_rect(); - let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; - let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("lights-out"))); - let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); - if !k.is_positive() { - p.rect_filled(screen, 0.0, veil); - return; - } - // four bands around the photo - use egui::{Rect, pos2}; - p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); - } - - /// Frame timings once layout is done (`t0`: when layout started). - fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { - if self.ui.frame_rate { - frame_rate::show(ctx, self); - } else { - frame_rate::forget(ctx); - } - panels::edit::wheel_pointer_frame_end(ctx); - ctx.output(|o| { - for c in &o.commands { - if let egui::OutputCommand::CopyText(text) = c { - self.copied = Some(text.clone()); - } - } - }); - self.perf.frame_ms = now_ms() - t0; - self.perf.update_ms = self.perf.logic_ms + self.perf.frame_ms; - self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); - } - - /// Once per library opened (at launch too): name it in the window title, so nobody edits in - /// the wrong one, and put it first in File ▸ Open Recent Library. - pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { - let generation = self.session.library_generation(); - if self.shown_library == Some(generation) { - return; - } - self.shown_library = Some(generation); - if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { - let path = l.dir.to_string_lossy().into_owned(); - panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); - // it is open: whatever an earlier check said about it is over - self.recent_checks.opened(&path); - // when, for the launch window (issue #1290); only the listed libraries keep a time - let settings = &mut self.ui.settings; - settings.recent_opened.insert(path, (self.session.clock)()); - settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); - } - ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); - } - - /// "LightCraft — " (just "LightCraft" for a library that isn't saved). - pub fn window_title(&self) -> String { - match panels::settings::open_library_name(self) { - Some(name) => format!("LightCraft — {name}"), - None => "LightCraft".to_string(), - } - } - - /// The browser page's title: "[ •] — LightCraft" for the active photo (• while - /// `unsaved`: changes not yet written to storage), else the window title. The web build has no - /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which - /// document a drop opened and whether it is saved (issue #2397). - pub fn page_title(&self, unsaved: bool) -> String { - let dot = if unsaved { " •" } else { "" }; - match self.session.active().and_then(|id| self.session.catalog.photo(id)) { - Some(p) => format!("{}{dot} — LightCraft", p.file_name), - None => format!("{}{dot}", self.window_title()), - } - } - - /// Lay out the whole window. - pub fn ui(&mut self, ui: &mut egui::Ui) { - i18n::set_language(self.ui.language); - let ctx = ui.ctx().clone(); - if !self.fonts_ready { - ctx.request_repaint(); - return; - } - panels::dialogs::prepare(self, ui); - let t0 = now_ms(); - if std::mem::take(&mut self.ui.quit) { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - self.library_changed(&ctx); - // panels set it again this frame while the pointer rests on a preset or profile - self.hover_preview = None; - self.ai_mask_detail(&ctx); - self.place_names_outcome(&ctx); - if self.ui.fullscreen { - // full-screen preview: the photo alone on black - egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) - .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); - panels::second::show(self, &ctx); - panels::notices::show(self, &ctx); - panels::dialogs::show(self, &ctx); - panels::library_problem::show(self, &ctx); - panels::toast(self, &ctx); - drop_overlay::show(self, &ctx); - self.widgets = widgets::take_registry(&ctx); - self.end_frame(&ctx, t0); - return; - } - // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, - // right panels and left panel run to the bottom; the bottom bar sits between them). - panels::topbar::show(self, ui); - panels::library_problem::banner(self, ui); - panels::strip::show(self, ui); - // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour - let chrome = theme::Tokens::get(&ctx).chrome; - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); - self.lights_out(&ctx); - panels::second::show(self, &ctx); - panels::notices::show(self, &ctx); - panels::dialogs::show(self, &ctx); - panels::library_problem::show(self, &ctx); - export_task::poll(self, &ctx); - slideshow_export::poll(self, &ctx); - panels::activity::show(self, &ctx); - export_task::poll_photos(self, &ctx); - pick::poll(self, &ctx); - panels::grid::drag_feedback(self, &ctx); - panels::left::album_drag_feedback(self, &ctx); - panels::left::section_drag_feedback(self, &ctx); - panels::keyword_list::drag_feedback(self, &ctx); - panels::toast(self, &ctx); - drop_overlay::show(self, &ctx); - self.widgets = widgets::take_registry(&ctx); - self.text_focus = ctx.egui_wants_keyboard_input(); - self.end_frame(&ctx, t0); - } -} - -/// How long a screenshot waits for in-flight renders. +/// How long a screenshot waits for in-flight renders before it is captured anyway (and the +/// headless fallback's settle budget). const SCREENSHOT_SETTLE_MS: f64 = 3000.0; /// How long a windowed screenshot waits for the compositor before falling back to headless. const SCREENSHOT_FALLBACK_MS: f64 = 2000.0; +/// Screenshots admitted at once (queued, issued and completed-but-undrained). +const MAX_PENDING_SHOTS: usize = 4; +/// Bytes one screenshot image may use. +const MAX_SHOT_BYTES: usize = 128 * 1024 * 1024; +/// Largest RGBA surface that fits [`MAX_SHOT_BYTES`], checked with the viewport dimensions before +/// layout. The CPU rasterizer's own `softpaint::MAX_IMAGE_PIXELS` may be larger; a snapshot that +/// cannot be captured must be rejected up front rather than after admission. +pub(crate) const MAX_SHOT_PIXELS: usize = MAX_SHOT_BYTES / std::mem::size_of::(); +/// Bytes all pending/completed screenshots may reserve together. +const MAX_SHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// Slack for PNG framing and deflate overhead while encoding one screenshot. zlib can exceed the +/// raw bytes slightly for incompressible input; this bounds that transient honestly. +const SHOT_ENCODE_SLACK_BYTES: usize = 64 * 1024; +/// Peak working set of capture encoding: every reserved image (≤ [`MAX_SHOT_TOTAL_BYTES`]) plus, +/// for the image being written, its `Rgba8` copy and encoded PNG (each ≤ [`MAX_SHOT_BYTES`] plus +/// [`SHOT_ENCODE_SLACK_BYTES`]). Encoding runs on the UI thread, one image at a time. +const MAX_SHOT_WORKING_SET_BYTES: usize = MAX_SHOT_TOTAL_BYTES + 2 * MAX_SHOT_BYTES + SHOT_ENCODE_SLACK_BYTES; +const _: () = { + assert!(MAX_SHOT_WORKING_SET_BYTES > MAX_SHOT_TOTAL_BYTES); + assert!(SHOT_ENCODE_SLACK_BYTES > 0); + assert!(MAX_SHOT_BYTES.is_multiple_of(std::mem::size_of::())); + assert!(MAX_SHOT_PIXELS * std::mem::size_of::() == MAX_SHOT_BYTES); +}; +/// Synthetic-input control requests waiting for a fresh layout/delivery at once. +const MAX_PENDING_GESTURES: usize = 32; +/// A control gesture is answered with an error after this long without a completed UI pass and +/// delivery (milliseconds). +/// How long synthetic input may wait without the UI consuming a batch. Each consumed batch +/// restarts this clock, so it is a stall limit, not a cap on a many-step drag's total time. +const GESTURE_TIMEOUT_MS: f64 = 5000.0; +/// Completed UI frames a request waits for before it is issued (a settled, laid-out frame). +const SHOT_SETTLE_FRAMES: u64 = 3; +/// Time a request waits before it is issued, so a control command that triggered a layout change +/// is not captured mid-change. +const SHOT_SETTLE_MS: f64 = 120.0; +/// A settled capture is issued at this instant even while renders are still in flight. The hard +/// admission deadline below still leaves the compositor fallback and the headless settle after it. +const SHOT_FORCE_BUSY_MS: f64 = SCREENSHOT_SETTLE_MS; +/// The texture limit when the host has not reported one. +const MAX_TEXTURE_SIDE: usize = 16_384; + +/// The viewport geometry a screenshot was issued for. A completion from an older generation is +/// rejected even if the window has since returned to the same size. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ShotGeometry { + /// Viewport size in egui points (the same units as `content_rect`). + viewport: egui::Vec2, + /// Egui pixels per point, including UI zoom. + pixels_per_point: f32, + /// The UI zoom factor. + zoom: f32, + /// [`LightcraftApp::shot_geometry_epoch`] at issue. + epoch: u64, +} + +impl ShotGeometry { + /// The surface pixels this geometry would produce, before the host's texture limit. + fn surface_pixels(self) -> Option<(usize, usize)> { + let w = (self.viewport.x * self.pixels_per_point).round(); + let h = (self.viewport.y * self.pixels_per_point).round(); + if !w.is_finite() || !h.is_finite() || w < 1.0 || h < 1.0 { + return None; + } + Some((w as usize, h as usize)) + } + + /// The surface pixels this geometry can produce, or `None` when it is not a usable surface or + /// exceeds one side of the host's texture limit. + fn pixels(self, max_texture_side: usize) -> Option<(usize, usize)> { + let (w, h) = self.surface_pixels()?; + (w <= max_texture_side && h <= max_texture_side).then_some((w, h)) + } + + /// Checked `width * height * 4` bytes for this geometry. + fn bytes(self, max_texture_side: usize) -> Option { + let (w, h) = self.pixels(max_texture_side)?; + w.checked_mul(h)?.checked_mul(std::mem::size_of::()) + } + + /// Why [`Self::bytes`] returned `None`: no laid-out surface, or one side over the host's limit. + /// The two are not the same user action (a driver/GL limit is not "the window has not drawn"). + fn rejection(self, max_texture_side: usize) -> String { + match self.surface_pixels() { + None => "ui.screenshot: the window has no usable surface size yet (no frame has been laid out)".to_string(), + Some((w, h)) => format!("ui.screenshot: the {w}×{h} pixel surface is over the host's {max_texture_side}-pixel per-side texture limit"), + } + } +} + +/// A synthetic-input control request waiting for a fresh layout and for its last event to be +/// consumed by a completed UI pass. +struct PendingGesture { + method: String, + params: Value, + reply: Sender, + /// [`LightcraftApp::layout_epoch`] at admission; resolution needs a later one. + admitted_epoch: u64, + frame: GestureFrame, + /// Monotonic stall deadline (milliseconds): delivery refreshes it whenever the UI consumes a + /// batch, so a long but progressing drag is not a timeout. + deadline_ms: f64, + /// [`LightcraftApp::ui_passes`] when the events were queued (`None`: not resolved yet). + resolved_pass: Option, + /// Synthetic events queued for this gesture that the host has not injected yet. + remaining_events: usize, + /// At least one batch of this gesture's events has reached a pass. Later batches may see the + /// gesture's own effect (a first click selects, the second opens), so the semantic frame + /// snapshot is only checked before the first one. + started: bool, + /// For a widget gesture, the id and point resolved against the completed layout. Delivery + /// rechecks the widget's fresh rect before the first event, before each press and (for a click) + /// before its release. + target: Option, + /// A pointer button this gesture has pressed but not yet released. Cancellation resets the + /// pointer state before egui's next input pass, so a refused click neither leaves the pointer + /// down nor turns the cleanup into a click or drag. + pressed: Option, + result: Value, +} /// A `ui.screenshot` request in progress. struct PendingShot { token: u64, - path: Option, + /// Destination identity pinned at admission (revalidated immediately before the write). + target: Option, reply: Sender, headless: bool, - not_before: f64, - deadline: f64, - frames: u32, + /// Admitted UI pass count and monotonic deadlines (milliseconds). + admitted_passes: u64, + not_before_ms: f64, + /// At this instant a busy renderer no longer holds the capture back. + force_ms: f64, + deadline_ms: f64, /// When the viewport screenshot command went out. - sent_at: Option, -} - -/// Wall-clock milliseconds since the Unix epoch (`web-time` maps to `Date.now()` on the web). -pub fn now_ms() -> f64 { - use web_time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) + sent_at_ms: Option, + /// Bytes this request reserves until it is drained or cancelled. + reserved: usize, + /// The one-shot completion slot; only the app owns the strong reference. + slot: Option, + /// The geometry and document generation the issued command was bound to. + issued: Option, + /// Library generation at issue (a different library changed mid-capture). + issued_library: u64, } /// Whether the settings render in black & white. @@ -1664,53 +512,6 @@ pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.") } -/// "Found 3 faces in 2 photos" for a `faces.detect` result. -fn detect_summary(v: &Value) -> String { - let photos = v["photos"].as_array().map(Vec::as_slice).unwrap_or_default(); - let counts: Vec = photos.iter().map(|p| p["faces"].as_array().map_or(0, Vec::len)).collect(); - let (faces, with) = (counts.iter().sum::(), counts.iter().filter(|n| **n > 0).count()); - let plural = |n: usize, w: &str| format!("{n} {w}{}", if n == 1 { "" } else { "s" }); - match (faces, photos.len()) { - (0, 1) => "No faces found".into(), - (0, n) => format!("No faces found in {}", plural(n, "photo")), - (f, 1) => format!("Found {}", plural(f, "face")), - (f, _) => format!("Found {} in {}", plural(f, "face"), plural(with, "photo")), - } -} - -/// What to say when a SAM 3 download ended while its dialog was closed: the text, and whether it is -/// shown as an error toast. A cancel is told by `cancelled`, not by the error's words. -fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> Option<(String, bool)> { - match &download.error { - Some(_) if download.cancelled => { - Some((crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time.").to_string(), false)) - } - Some(e) => Some((crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e), true)), - None if download.finished => Some((crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.").to_string(), true)), - None => None, - } -} - -/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`. -#[derive(Debug)] -struct InjectedDrop(std::path::PathBuf); - -impl egui::DroppedFile for InjectedDrop { - fn path(&self) -> &std::path::Path { - &self.0 - } - - #[cfg(not(target_arch = "wasm32"))] - fn bytes(&self) -> Result, String> { - std::fs::read(&self.0).map_err(|e| e.to_string()) - } - - #[cfg(target_arch = "wasm32")] - fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { - Box::pin(async { Err("the web build has no files to read".to_string()) }) - } -} - /// A dropped face model file (`.onnx`). pub fn is_model_file(path: &str) -> bool { std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) @@ -1744,50 +545,6 @@ pub fn split_presets(dropped: Vec) -> (Vec, Vec) { (presets, photos) } -#[cfg(test)] -mod drop_tests { - #[test] - 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); - assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); - assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); - } - - #[test] - 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"); - } - - #[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}"); - } - for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { - assert!(!super::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}"); - } - for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { - assert!(!super::is_preset_file(p), "{p}"); - } - } -} - /// Results recomputed only when the catalog (or their inputs) change. #[derive(Default)] pub struct Caches { @@ -1863,473 +620,3 @@ pub struct Caches { /// What the grid did on its frames (benchmarks and tests check unchanged frames stay cheap). pub grid_stats: panels::grid::GridStats, } - -/// The left panel's counts. -#[derive(Clone, Copy, Debug, Default)] -pub struct LibraryCounts { - pub total: usize, - pub picks: usize, - pub deleted: usize, -} - -/// The values the filter bar's pickers offer. -#[derive(Clone, Debug, Default)] -pub struct FilterValues { - pub cameras: Vec, - pub lenses: Vec, - pub keywords: Vec, -} - -/// What the smart-album rule dialog shows besides the rules themselves: their problems (each row -/// marks its own), how many photos they match and the albums an Album rule picks from. -pub struct RulesView { - pub problems: Vec, - pub count: usize, - pub albums: Vec, -} - -pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { - use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher}; - BuildHasherDefault::::default().hash_one(parts) -} - -/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only -/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's -/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the -/// library saved on every edit. -fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool { - revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads)) -} - -/// What the tallies below read of a photo, besides whether it is in the library. -const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE); -const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META); -const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS); -const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED); -const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG); - -impl Caches { - /// The folders the library's photos were imported from. - pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t); - let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree())); - self.folder_tree = Some((cat.revision, t.clone())); - t - } - /// The folder tree as last built, when it still holds (a read-only check cannot rebuild it). - pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option>> { - self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone()) - } - /// The library's keyword tree. - pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); - let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree())); - self.keyword_tree = Some((cat.revision, t.clone())); - t - } - /// The Keywording box's read-only names for the selection: what exported files carry - /// (`export`), or the keywords with those containing them. - pub(crate) fn keyword_names( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - export: bool, - ) -> std::sync::Arc> { - let key = key_of((selection, export)); - match &self.keyword_names { - Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), - _ => { - let c = std::sync::Arc::new(if export { - panels::keywording::will_export(cat, selection) - } else { - panels::keywording::with_containing(cat, selection) - }); - self.keyword_names = Some((cat.revision, key, c.clone())); - c - } - } - } - /// The selection's keywords (the Keywording box's chips). - pub(crate) fn keyword_chips( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - ) -> std::sync::Arc> { - let key = key_of(selection); - match &self.keyword_chips { - Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), - _ => { - let c = std::sync::Arc::new(panels::keywording::chips(cat, selection)); - self.keyword_chips = Some((cat.revision, key, c.clone())); - c - } - } - } - /// How many photos the selection is: each once, those the library has (what the Keywording - /// box's "N of M" counts). - pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize { - let key = key_of(selection); - match self.selected_photos { - Some((r, k, n)) if r == cat.revision && k == key => n, - _ => { - let n = panels::keywording::distinct(cat, selection).len(); - self.selected_photos = Some((cat.revision, key, n)); - n - } - } - } - /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). - pub(crate) fn keyword_groups( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - ) -> std::sync::Arc> { - let key = key_of(selection); - match &self.keyword_groups { - Some((r, k, g)) if *r == cat.revision && *k == key => g.clone(), - _ => { - let chips = self.keyword_chips(cat, selection); - let g = std::sync::Arc::new(panels::keywording::grouped(&chips)); - self.keyword_groups = Some((cat.revision, key, g.clone())); - g - } - } - } - /// How many selected photos have each keyword (the Keyword List's tick boxes). - pub(crate) fn keyword_ticks( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - ) -> std::sync::Arc { - let key = key_of(selection); - match &self.keyword_ticks { - Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(), - _ => { - let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection)); - self.keyword_ticks = Some((cat.revision, key, t.clone())); - t - } - } - } - /// The people named on faces among the photos the filter lets through (its own `person` aside), - /// with photo counts. - pub fn people( - &mut self, - cat: &lightcraft_catalog::Catalog, - filter: &lightcraft_catalog::Filter, - ) -> std::sync::Arc> { - let key = lightcraft_catalog::Filter { person: None, ..filter.clone() }; - match &self.people { - Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), - _ => { - let t = std::sync::Arc::new(cat.people_in(&key)); - self.people = Some((cat.revision, key, t.clone())); - t - } - } - } - /// All Photos / Picks / Recently Deleted counts. - pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { - if let Some((r, c)) = self.counts - && still_right(cat, r, READS_FLAG) - { - self.counts = Some((cat.revision, c)); - return c; - } - let mut c = LibraryCounts::default(); - for p in cat.photos() { - if p.in_library() { - c.total += 1; - if p.flag == lightcraft_catalog::Flag::Pick { - c.picks += 1; - } - } else if p.deleted && !p.local { - c.deleted += 1; - } - } - self.counts = Some((cat.revision, c)); - c - } - /// Everyone named on a face in the library (for completing a name as it is typed). - pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v); - let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::>())); - self.person_names = Some((cat.revision, v.clone())); - v - } - /// The By Date tree. - pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g); - let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups())); - self.date_groups = Some((cat.revision, g.clone())); - g - } - /// Cameras, lenses and keywords in the library (filter bar pickers). - pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc { - match &self.filter_values { - Some((r, v)) if *r == cat.revision => v.clone(), - _ => { - let distinct = |mut v: Vec| { - v.retain(|s| !s.trim().is_empty()); - v.sort_by_key(|s| s.to_lowercase()); - v.dedup(); - v - }; - let lib = || cat.photos().filter(|p| p.in_library()); - let v = std::sync::Arc::new(FilterValues { - cameras: distinct(lib().map(|p| p.meta.camera.clone()).collect()), - lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()), - keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(), - }); - self.filter_values = Some((cat.revision, v.clone())); - v - } - } - } - /// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart - /// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks - /// through the library's keywords. - pub(crate) fn completions( - &mut self, - cat: &lightcraft_catalog::Catalog, - current: &[String], - typed: &str, - ) -> std::sync::Arc { - let k = key_of((cat.revision, current, typed)); - match &self.completions { - Some((h, v)) if *h == k => v.clone(), - _ => { - let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed)); - self.completions = Some((k, v.clone())); - v - } - } - } - /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. - pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc> { - let k = key_of((cat.revision, current, prefix, n)); - match &self.suggestions { - Some((h, v)) if *h == k => v.clone(), - _ => { - let v = std::sync::Arc::new(cat.keyword_suggestions(current, prefix, n)); - self.suggestions = Some((k, v.clone())); - v - } - } - } - /// Every album's photo count for the sidebar (a smart album evaluates its rules, which - /// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules - /// all bump its revision) and — only while some smart album has an "in the last…" rule — - /// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock - /// within a minute without rescanning every frame. - /// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within - /// `folder` when it was made from a folder view: worked out again only when the catalog, the - /// rules, the language or (for "in the last…" rules) the minute changes, not every frame. - pub fn rules_view( - &mut self, - cat: &lightcraft_catalog::Catalog, - rules: &lightcraft_catalog::RuleSet, - editing: Option, - folder: Option, - now: &str, - ) -> std::sync::Arc { - let relative = rules.depends_on_now(); - let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code())); - if let Some((key, v)) = &self.rules_view - && *key == k - { - return v.clone(); - } - if relative { - lightcraft_catalog::rules::set_now(Some(now.to_string())); - } - let problems = rules.check_for(cat, editing.map(lightcraft_catalog::AlbumId)); - let filter = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; - let count = if problems.is_empty() { cat.query(&filter, &Default::default()).len() } else { 0 }; - let v = std::sync::Arc::new(RulesView { problems, count, albums: panels::dialogs::album_entries(cat, editing) }); - self.rules_view_scans += 1; - self.rules_view = Some((k, v.clone())); - v - } - - /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs - /// again only when the catalog or `what` (what is counted) changes, not every frame. - pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { - let k = key_of((cat.revision, what)); - if let Some((key, n)) = self.dialog_count - && key == k - { - return n; - } - let n = count(); - self.dialog_count_scans += 1; - self.dialog_count = Some((k, n)); - n - } - - /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), - /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. - pub fn smart_album_problems( - &mut self, - cat: &lightcraft_catalog::Catalog, - ) -> std::sync::Arc>> { - if let Some((rev, v)) = &self.smart_problems - && *rev == cat.revision - { - return v.clone(); - } - let v: std::sync::Arc> = std::sync::Arc::new( - cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(), - ); - self.smart_problem_scans += 1; - self.smart_problems = Some((cat.revision, v.clone())); - v - } - - pub fn album_counts( - &mut self, - cat: &lightcraft_catalog::Catalog, - now: &str, - ) -> std::sync::Arc> { - let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); - let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - // the counts of this minute, if there are any: an edit to a photo or a few is followed from them, a smart - // album asking only those photos again; anything more, or a new minute, is counted from every photo - let kept = self.album_counts.take().filter(|(of, _, _)| of == minute); - if let Some((_, tally, shown)) = &kept - && tally.revision() == cat.revision - { - let shown = shown.clone(); - self.album_counts = kept; - return shown; - } - if relative { - // "in the last N days" counts back from the session clock, as in the grid. (Within a minute an edit asks - // only the photos it touched, at this second; the others were asked earlier in the minute. That is as - // far out as the counts already were between edits, and the next minute counts them all again.) - lightcraft_catalog::rules::set_now(Some(now.to_string())); - } - let tally = match kept { - Some((_, mut tally, _)) => { - if !tally.update(cat) { - self.album_count_scans += 1; - } - tally - } - None => { - self.album_count_scans += 1; - lightcraft_catalog::tally::AlbumTally::of(cat) - } - }; - let shown = std::sync::Arc::new(tally.counts().clone()); - self.album_counts = Some((minute.to_string(), tally, shown.clone())); - shown - } -} - -#[cfg(test)] -mod cache_tests { - use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source}; - - fn photo(id: u64, captured: &str, rating: u8) -> Op { - let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); - p.captured = Some(captured.into()); - p.rating = rating; - Op::AddPhoto { photo: Box::new(p) } - } - - fn smart(id: u64, rules: serde_json::Value) -> Op { - let mut a = Album::new(AlbumId(id), format!("smart {id}")); - let rules: RuleSet = serde_json::from_value(rules).unwrap(); - a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() })); - Op::AddAlbum { album: a } - } - - /// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for - /// "in the last…" rules) the clock crossing into a new minute recompute them. - #[test] - fn smart_album_counts_are_cached_until_something_changes() { - let mut cat = Catalog::default(); - let mut c = super::Caches::default(); - for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() { - cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap(); - } - cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); - cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap(); - let now = "2026-09-30T12:00:00"; - let n = c.album_counts(&cat, now); - assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1)); - assert_eq!(c.album_count_scans, 1); - for _ in 0..10 { - c.album_counts(&cat, "2026-09-30T12:00:40"); - } - assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan"); - // a photo rated: followed from that photo, not counted from every one - cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); - assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2); - assert_eq!(c.album_count_scans, 1); - // rule change - let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap(); - cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap(); - assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1); - // an hour later the 11:59:30 photo has left "in the last hour", with no catalog change - assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0); - assert_eq!(c.album_count_scans, 3, "new rules and a new minute are counted from every photo; the rating was not"); - lightcraft_catalog::rules::set_now(None); - } - - /// The rule dialog's problems, live count and album list are worked out once per change to the - /// catalog or the rules, not every frame; so are the sidebar's smart-album problems. - #[test] - fn rule_dialog_and_problems_are_cached_until_something_changes() { - let mut cat = Catalog::default(); - let mut c = super::Caches::default(); - for i in 0..3u64 { - cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap(); - } - let rules = - |v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() }; - let now = "2026-09-30T12:00:00"; - let view = c.rules_view(&cat, &rules(3), None, None, now); - assert_eq!((view.count, view.problems.len()), (1, 0)); - for _ in 0..10 { - c.rules_view(&cat, &rules(3), None, None, now); - } - assert_eq!(c.rules_view_scans, 1, "unchanged frames reuse it"); - assert_eq!(c.rules_view(&cat, &rules(1), None, None, now).count, 3, "a rule change"); - assert_eq!(c.rules_view(&cat, &rules(9), None, None, now).problems.len(), 1); - cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); - assert_eq!(c.rules_view(&cat, &rules(3), None, None, now).count, 2, "a catalog change"); - assert_eq!(c.rules_view_scans, 4); - // the sidebar's problems - cat.apply(Op::AddAlbum { album: lightcraft_catalog::Album::new(AlbumId(20), "Trip") }).unwrap(); - cat.apply(smart(21, serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 20}]}))).unwrap(); - assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_none_or(Vec::is_empty)); - for _ in 0..10 { - c.smart_album_problems(&cat); - } - assert_eq!(c.smart_problem_scans, 1); - cat.apply(Op::RemoveAlbum { id: AlbumId(20) }).unwrap(); - assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_some_and(|p| !p.is_empty()), "its album is gone"); - assert_eq!(c.smart_problem_scans, 2); - } - - /// Without "in the last…" rules the clock never causes a rescan. - #[test] - fn absolute_rules_ignore_the_clock() { - let mut cat = Catalog::default(); - let mut c = super::Caches::default(); - cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap(); - cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); - c.album_counts(&cat, "2026-09-30T12:00:00"); - c.album_counts(&cat, "2027-01-01T00:00:00"); - assert_eq!(c.album_count_scans, 1); - } -} - -#[cfg(test)] -mod tests_model_setup; - -#[cfg(test)] -mod tests_docking_acceptance; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..b6b6c66c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -8,2227 +8,20 @@ use crate::LightcraftApp; use crate::pick::{PickRequest, Picked}; use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; +mod files; +mod run; +mod ui_commands; + +pub use files::{confirm_delete, delete_in_album, reveal_label, show_in_folders_enabled}; +pub use run::{parse_rgb, run_ui_command}; +pub use ui_commands::{LANGUAGE_COMMANDS, UI_COMMANDS, language_from_command, ui_commands}; + +pub mod menu_level; +pub mod menubar; + /// (id, label, shortcut, menu path) pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); -/// 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. -pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ - ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), - // The picker order (`Locale::for_picker`): alphabetical by the language's own name after - // English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal. - ("app.language.indonesian", crate::i18n::Locale::Id.name(), None, "Edit>Language"), - ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), - ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), - ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), - ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), - ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), - ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), - ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), - ("app.language.finnish", crate::i18n::Locale::Fi.name(), None, "Edit>Language"), - ("app.language.swedish", crate::i18n::Locale::Sv.name(), None, "Edit>Language"), - ("app.language.vietnamese", crate::i18n::Locale::Vi.name(), None, "Edit>Language"), - ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), - ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), - ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), - ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), - ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), - ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), -]; - -/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from -/// this file, so an id listed in `docs/parity-checklist.md` is checked wherever it is declared. -pub fn ui_commands() -> impl Iterator { - LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) -} - -/// The language a Language-menu command selects, if the id is one. The engine and the UI both go -/// through here, so the menu, the settings row and the control channel agree on the mapping. -pub fn language_from_command(id: &str) -> Option { - match id { - "app.language.english" => Some(crate::i18n::Locale::En), - "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), - "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), - "app.language.japanese" => Some(crate::i18n::Locale::Ja), - "app.language.portuguese" => Some(crate::i18n::Locale::PtBr), - "app.language.french" => Some(crate::i18n::Locale::Fr), - "app.language.spanish" => Some(crate::i18n::Locale::Es), - "app.language.german" => Some(crate::i18n::Locale::De), - "app.language.russian" => Some(crate::i18n::Locale::Ru), - "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), - "app.language.polish" => Some(crate::i18n::Locale::Pl), - "app.language.czech" => Some(crate::i18n::Locale::Cs), - "app.language.italian" => Some(crate::i18n::Locale::It), - "app.language.georgian" => Some(crate::i18n::Locale::Ka), - "app.language.swedish" => Some(crate::i18n::Locale::Sv), - "app.language.finnish" => Some(crate::i18n::Locale::Fi), - "app.language.indonesian" => Some(crate::i18n::Locale::Id), - "app.language.vietnamese" => Some(crate::i18n::Locale::Vi), - _ => None, - } -} - -pub const UI_COMMANDS: &[UiCommand] = &[ - ("ui.dock", "Arrange Panel", None, ""), - ("ui.dock.reset", "Reset Panel Layout", None, "Window"), - ("modelSetup.cancel", "Cancel Pending Photo Action", None, ""), - ("view.photoGrid", "Photo Grid", None, "View"), - ("view.squareGrid", "Square Grid", None, "View"), - // G: Photo Grid ↔ Square Grid (from other views: the photo grid) - ("view.gridToggle", "Grid", Some("G"), ""), - ("library.selectOnlyActive", "Select Only Active Photo", Some("Cmd+Shift+D"), "Edit"), - ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), - ("view.detail", "Detail View", Some("D"), "View"), - ("view.compare", "Compare", Some("Shift+C"), "View"), - ("view.survey", "Survey", Some("N"), "View"), - ("view.people", "People", None, "View"), - ("view.person", "Show Person", None, ""), - ("view.faceBoxes", "Face Boxes", None, "View"), - ("view.focusPoint", "Focus Point", Some("Alt+F"), "View"), - ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), - ("view.reference", "Reference View", Some("Shift+R"), "View"), - ("photo.setReference", "Set as Reference Photo", None, ""), - ("compare.swap", "Swap Compare Photos", None, "View"), - ("compare.set", "Show in Compare", None, ""), - ("compare.makeSelect", "Make Candidate the Select", None, "View"), - ("view.autoAdvance", "Auto Advance", None, "Photo"), - ("view.filmstrip", "Filmstrip", Some("/"), "View"), - ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), - ("view.rightPanel", "Right Panel", None, "View"), - ("view.beforeAfter", "Before/After Side by Side", Some("Y"), "View"), - ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), - ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), - ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), - ("view.showOriginal", "Show Original", Some("\\"), "View"), - ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), - ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), - ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), - // the ratio a click (and Z / Space) zooms to - ("view.clickZoom", "Click Zoom Ratio", None, ""), - ("view.navigate", "Set Image Zoom and Pan", None, ""), - ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), - ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), - // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key - ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), - ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), - ("view.clipping", "Clipping", Some("J"), "View"), - ("view.visualizeHdr", "Visualize HDR", None, "View"), - // in grids S expands/collapses stacks (the engine command it shadows) - ("view.softProof", "Soft Proofing", Some("S"), "View"), - ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), - ("view.maskOverlay", "Mask Overlay", Some("O"), "View"), - // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one - ("view.maskOverlayAll", "All Mask Overlays", Some("Cmd+Shift+O"), "View"), - // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) - ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), - ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), - ("view.maskPins", "Mask Pins", None, "View"), - // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next - // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool - // Overlay and the Remove panel list the modes. - ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), - ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), - // A while the Crop panel is open: the aspect lock as a first-class row. Visualize Spots keeps - // the key outside the panel; the two are a contextual pair in shortcuts::handle - ("crop.lockAspect", "Lock Aspect Ratio", Some("A"), ""), - ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), - ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), - ("view.back", "Back to Grid", Some("Escape"), ""), - ("tool.done", "Done", Some("Enter"), ""), - ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), - ("local.addRoot", "Add Folder to Local", None, ""), - ("local.hide", "Remove from Local", None, ""), - ("local.restoreHidden", "Show Hidden Local Locations", None, ""), - ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), - ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), - ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), - ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), - ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), - ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), - ("view.layoutImage", "Layout Image", Some("Cmd+Alt+O"), "View"), - ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), - ("view.layoutImageMatte", "Layout Image Matte", None, ""), - ("view.navigator", "Navigator", None, "View"), - ("panel.edit", "Edit Panel", Some("E"), "Window"), - ("panel.profiles", "Profile Browser", None, "Window"), - ("panel.crop", "Crop & Rotate", Some("C"), "Window"), - ("panel.remove", "Remove", Some("H"), "Window"), - ("panel.masking", "Masking", Some("M"), "Window"), - ("panel.redeye", "Red Eye", None, "Window"), - ("panel.presets", "Presets", Some("Shift+P"), "Window"), - ("panel.info", "Info", Some("I"), "Window"), - ("panel.keywords", "Keywords", Some("K"), "Window"), - ("panel.versions", "Versions", Some("Shift+V"), "Window"), - ("panel.activity", "History", None, "Window"), - ("panel.close", "Close Panel", None, ""), - // {solo?}: true opens just that section (⌥-click on its header); otherwise opens or closes it - ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), - ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), - ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), - ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), - ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), - ("section.calibration", "Calibration", None, ""), - // {on?}; no params toggles. Opening one Edit section closes the others. - ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), - ("tool.brush", "Brush", Some("B"), "Window>Tools"), - ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), - ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), - ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), - ("tool.none", "No Tool", None, ""), - // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) - ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), - ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), - ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), - ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), - ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), - ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), - ("dialog.smartAlbum", "New Smart Album…", None, "File"), - ("view.photoCounts", "Photo Counts", None, "View"), - ("view.sidebarSection", "Show Sidebar Section", None, ""), - ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), - ("view.sidebarReset", "Reset Sidebar Sections", None, ""), - ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), - ("view.slideshowOptions", "Slideshow Options…", None, "View"), - ("dialog.slideshowExport", "Export Slideshow…", None, "View"), - ("app.slideshowExport", "Export Slideshow", None, ""), - // the slideshow's playback controls (Space, ←/→ and Esc while it plays) - ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), - ("slideshow.next", "Next Slide", None, ""), - ("slideshow.previous", "Previous Slide", None, ""), - ("slideshow.end", "End Slideshow", None, ""), - ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), - ("tool.keywordPainter", "Keyword Painter", None, ""), - ("view.gridInfo", "Grid Info", None, ""), - // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the - // top bar's appearance button). View ▸ Appearance lists the modes. - ("view.appearance", "Next Appearance Mode", None, ""), - // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. - ("view.theme", "Theme", None, ""), - ("dialog.allMetadata", "All Metadata…", None, "Photo"), - ("dialog.faceModel", "Add Face Model…", None, ""), - ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), - ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), - ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), - ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), - ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), - ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), - ("dialog.export", "Export…", None, "File"), - ("dialog.print", "Print…", Some("Cmd+P"), "File"), - ("app.print", "Print to PDF", None, ""), - ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), - ("app.contactSheet", "Export Contact Sheet PDF", None, ""), - ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), - ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), - // {ids?, app?}: the original files themselves (a raw stays a raw: no render, no copy), opened - // in the other editor, e.g. a raw developer - ("photo.openOriginalInExternal", "Open Original in Other Editor (RAW, no copy)", None, "Photo>Edit In"), - ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), - ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), - ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), - ("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"), - ("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"), - ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), - ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), - ("file.addFolder", "Import from Folder…", None, "File"), - // {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or - // Settings) says (#2672, `lightroom_destination`) - ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), - ("file.addFromDevice", "Import from Device", None, ""), - ("file.findMissing", "Find Missing Photos…", None, "File"), - ("file.backupLibrary", "Back Up Library…", None, "File"), - ("file.restoreLibrary", "Restore Library from Backup…", None, "File"), - ("photo.locate", "Locate Missing File…", None, ""), - ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), - ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), - ("file.importPresets", "Import Profiles & Presets…", None, "File"), - ("file.exportPresets", "Export Presets…", None, "File"), - // Masking panel ▸ Effect menu - ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), - ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), - ("file.exportMaskEffects", "Export Effect Presets…", None, ""), - ("file.importKeywords", "Import Keywords…", None, "File"), - ("file.exportKeywords", "Export Keywords…", None, "File"), - // Edit panel ▸ Curve ▸ Point Curve dropdown - ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), - ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), - ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), - ("app.newLibrary", "New Library…", None, "File"), - ("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"), - ("app.recentLibraries", "Recent Libraries", None, ""), - ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), - // Settings ▸ Display - ("app.displayProfile", "Display Profile", None, ""), - ("app.about", "About LightCraft", None, "Help"), - ("app.systemInfo", "System Info…", None, "Help"), - ("app.openLogFolder", "Open Log Folder", None, "Help"), - // the frame-rate display (`frame_rate`): a debugging aid, never saved - ("view.frameRate", "Frame Rate", None, "Help>Debug"), - ("app.whatsNew", "What's New", None, "Help"), - ("dialog.cull", "Assisted Culling…", None, "Photo"), - ("app.help", "LightCraft Help", Some("F1"), "Help"), - ("app.discord", "Join the ArtCraft Discord…", None, "Help"), - ("app.feedback", "Send Feedback…", None, "Help"), - ("app.website", "LightCraft Website", None, "Help"), - ("app.github", "LightCraft on GitHub", None, "Help"), - ("app.artcraft", "ArtCraft Website", None, "Help"), - ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), - ("app.setShortcut", "Set Keyboard Shortcut", None, ""), - ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), - ("app.export", "Export Now", None, ""), - ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), - ("app.showInFolders", "Show in Folders", None, "Photo"), - ("app.openSamModelFolder", "Show in File Manager", None, ""), - ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), - ("dialog.labelNames", "Edit Color Label Names…", None, ""), - ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), - ("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"), - ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), -]; - -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) { - app.ui.right = RightPanel::None; - app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); - } else { - app.ui.right = p; - app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name))); - if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - } - if p != RightPanel::Masking && app.ui.tool != "wbPicker" { - app.ui.tool.clear(); - } - let _ = app.session.end_interaction(); -} - -/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's -/// (and its selected spot's) or the Masking brush's. -fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { - if app.ui.right == RightPanel::Remove { - app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); - app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); - if app.session.active_spot.is_some() { - let mut p = json!({}); - if k != 1.0 { - p["size"] = json!(app.ui.remove_size); - } - if df != 0.0 { - p["feather"] = json!(app.ui.remove_feather); - } - let _ = app.run("spot.update", p); - } - json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) - } else { - app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); - app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); - json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) - } -} - -/// The `parent` folder id of a `dialog.new*` command (none: the top level). -fn parent_param(p: &Value) -> Option { - p.get("parent").and_then(Value::as_u64) -} - -/// `file.addPhotos` / `file.addFolder` / `file.addFromDevice` `{album?}`: the review of the scan -/// just started opens with that album chosen (an album's context menu ▸ Import ▸ …). An error -/// means another scan is running. -fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result) -> Result { - if r.is_ok() - && let Some(t) = &mut app.scan - { - t.album = p.get("album").and_then(Value::as_u64); - } - r -} - -/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). -pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { - if let Some(s) = v.as_str() { - let h = s.strip_prefix('#').unwrap_or(s); - if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { - return None; - } - let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); - return Some([c(0)?, c(2)?, c(4)?]); - } - let a = v.as_array()?; - let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); - Some([c(0)?, c(1)?, c(2)?]) -} - -/// The left-sidebar section a `view.sidebar*` command names (`section`), which this build must have. -fn sidebar_section_param(command: &str, p: &Value) -> Result { - use crate::sidebar::SidebarSection; - let Some(name) = p.get("section").and_then(Value::as_str) else { - return Err(format!("{command}: `section` is needed ({})", SidebarSection::ids())); - }; - let section = SidebarSection::from_id(name).ok_or_else(|| format!("{command}: unknown section `{name}` ({})", SidebarSection::ids()))?; - if !section.available() { - return Err(format!("{command}: there is no `{name}` section here")); - } - Ok(section) -} - -/// An optional true / false parameter: absent or null is `None`, anything else is refused. -fn bool_param(command: &str, p: &Value, key: &str) -> Result, String> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(other) => Err(format!("{command}: `{key}` must be true or false, not {other}")), - } -} - -/// What Shift+H says it switched to (the pins themselves may be off screen). -fn tool_overlay_toast(mode: crate::state::ToolOverlay) -> String { - use crate::state::ToolOverlay; - crate::i18n::tr(match mode { - ToolOverlay::Auto => "Tool Overlay: Auto", - ToolOverlay::Always => "Tool Overlay: Always", - ToolOverlay::Selected => "Tool Overlay: Selected", - ToolOverlay::Never => "Tool Overlay: Never", - }) - .to_string() -} - -fn appearance_json(app: &LightcraftApp) -> Value { - let s = &app.ui.settings; - json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) -} - -/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). -/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). -fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { - use crate::state::AppearanceMode; - let mode = match p.get("mode") { - None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), - Some(v) => match v.as_str() { - Some("auto") => AppearanceMode::Auto, - Some("light") => AppearanceMode::Light, - Some("dark") => AppearanceMode::Dark, - _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), - }, - }; - app.ui.settings.appearance_mode = mode; - Ok(appearance_json(app)) -} - -/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). -fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { - let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); - let Some(id) = p.get("theme").and_then(Value::as_str) else { - return Err(format!("view.theme: `theme` is required ({ids})")); - }; - let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { - return Err(format!("view.theme: unknown theme `{id}` ({ids})")); - }; - app.ui.settings.select_theme(kind); - Ok(appearance_json(app)) -} - -/// Handle UI commands; `None` means "not a UI command — send it to the engine". -pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { - if matches!(id, "ui.dock" | "ui.dock.reset") { - return Some(crate::docking::command(app, id == "ui.dock.reset", p)); - } - if matches!(id, "library.inspectLightroom" | "library.importLightroom") { - // the app's own context: a fresh one's repaints reach no window and its clock starts at zero - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - return Some(crate::lightroom_import::command(app, id, p, &ctx)); - } - if id == "photo.reload" { - // the files are read in the background (a raw's decode takes a second or more) - return crate::reload::command(app, p); - } - if let Some(language) = language_from_command(id) { - app.ui.language = language; - // Immediately, not on the next frame: the reply and anything else run this frame - // (menus rebuilt from it, toasts) are already in the new language. - crate::i18n::set_language(app.ui.language); - return Some(Ok(json!(app.ui.language))); - } - // the app's own context: a toast's time comes from its clock (a fresh context's clock is at 0, - // and a toast set by it was long over by the app's clock: it never showed) - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - let r: Result = match id { - "view.photoGrid" => { - app.ui.view = ViewMode::PhotoGrid; - Ok(Value::Null) - } - "view.squareGrid" => { - app.ui.view = ViewMode::SquareGrid; - Ok(Value::Null) - } - "view.gridInfo" => { - // {info?: filename | exposure | date} (cycles when omitted) - let next = match p.get("info").and_then(Value::as_str) { - Some(i @ ("filename" | "exposure" | "date")) => i.to_string(), - Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))), - None => match app.ui.grid_info.as_str() { - "filename" => "exposure".into(), - "exposure" => "date".into(), - _ => "filename".into(), - }, - }; - app.ui.grid_info = next; - app.ui.show_filenames = true; - // the caption is drawn on Square Grid cells only: from Photo Grid, show it there - if app.ui.view == ViewMode::PhotoGrid { - app.ui.view = ViewMode::SquareGrid; - } - Ok(json!({"info": app.ui.grid_info})) - } - "tool.keywordPainter" => { - // {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops - app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string); - if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { - app.ui.view = ViewMode::PhotoGrid; - } - if let Some(k) = app.ui.keyword_painter.clone() { - app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k)); - } - Ok(json!({"keyword": app.ui.keyword_painter})) - } - "view.secondWindow" => { - app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window); - Ok(json!({"show": app.ui.second_window})) - } - "view.photoCounts" => { - app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); - Ok(json!({"show": app.ui.show_counts})) - } - "view.sidebarSection" => { - // {section, show?, collapsed?}: show or hide a section of the left sidebar, fold or - // unfold it; with neither, a shown section is hidden and a hidden one shown - let args = sidebar_section_param(id, p).and_then(|s| Ok((s, bool_param(id, p, "show")?, bool_param(id, p, "collapsed")?))); - let (section, show, collapsed) = match args { - Ok(args) => args, - Err(e) => return Some(Err(e)), - }; - let layout = &mut app.ui.sidebar; - match (show, collapsed) { - (None, None) => layout.set_hidden(section, !layout.is_hidden(section)), - _ => { - if let Some(show) = show { - layout.set_hidden(section, !show); - } - if let Some(collapsed) = collapsed { - layout.set_collapsed(section, collapsed); - } - } - } - Ok(json!({"section": section.id(), "show": !layout.is_hidden(section), "collapsed": layout.is_collapsed(section)})) - } - "view.sidebarMoveSection" => { - // {section, direction: up | down} one place among the shown sections, or - // {section, before:
| null} just above another section (null: last) - let section = match sidebar_section_param(id, p) { - Ok(s) => s, - Err(e) => return Some(Err(e)), - }; - let layout = &mut app.ui.sidebar; - let moved = match (p.get("direction"), p.get("before")) { - (Some(d), None) => match d.as_str() { - Some("up") => layout.move_by(section, true), - Some("down") => layout.move_by(section, false), - _ => return Some(Err(format!("{id}: `direction` must be \"up\" or \"down\", not {d}"))), - }, - (None, Some(Value::Null)) => layout.move_before(section, None), - (None, Some(b)) => { - let before = b.as_str().and_then(crate::sidebar::SidebarSection::from_id); - let Some(before) = before else { - return Some(Err(format!("{id}: `before` must be a section ({}) or null, not {b}", crate::sidebar::SidebarSection::ids()))); - }; - layout.move_before(section, Some(before)) - } - _ => return Some(Err(format!("{id}: give either `direction` (up|down) or `before` (a section, or null for last)"))), - }; - let order: Vec<&str> = layout.sections().iter().map(|s| s.id.id()).collect(); - Ok(json!({"section": section.id(), "moved": moved, "order": order})) - } - "view.sidebarReset" => { - // every section shown and open, in the usual order - app.ui.sidebar = Default::default(); - Ok(json!({"sidebar": app.ui.sidebar})) - } - "view.appearance" => appearance_command(app, p), - "view.theme" => theme_command(app, p), - "library.selectOnlyActive" => { - let Some(active) = app.session.active() else { return Some(Err("no active photo".into())) }; - app.run("library.select", json!({"ids": [active.0], "active": active.0})) - } - "view.gridToggle" => { - app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; - Ok(Value::Null) - } - "view.detail" => { - app.ui.view = ViewMode::Detail; - Ok(Value::Null) - } - "view.compare" => crate::panels::compare::enter_compare(app), - "view.focusPoint" => { - // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. - app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); - Ok(json!({"on": app.ui.settings.focus_point})) - } - "view.zoomToFocus" => { - // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. - app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); - Ok(json!({"on": app.ui.settings.zoom_to_focus})) - } - "view.faceBoxes" => { - app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); - Ok(json!({"show": app.ui.face_boxes})) - } - "view.people" => { - // Everyone; the page just left stays one click away (the chip next to the title) - if let Some(name) = app.ui.person_page.take() { - app.ui.last_person = Some(name); - } - app.ui.person_from = None; - app.ui.view = ViewMode::People; - Ok(json!({"people": app.session.catalog.people().len()})) - } - "view.person" => { - // {name}: one person's page in the People view: their faces, and the faces that look like them - let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { - return Some(Err("view.person: missing `name`".into())); - }; - app.ui.view = ViewMode::People; - app.ui.person_from = None; - app.ui.person_page = Some(name.to_string()); - Ok(json!({"person": name})) - } - "view.survey" => { - app.ui.view = ViewMode::Survey; - Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) - } - "view.reference" => { - // the reference: the one set before, else the active photo (the next one becomes active) - let vis = app.session.visible_cloned(); - let live = |id: u64| crate::panels::compare::live(app, lightcraft_catalog::PhotoId(id)); - let active = app.session.active().map(|a| a.0); - let reference = app.ui.reference.filter(|r| live(*r)).or_else(|| active.filter(|a| live(*a))); - let Some(r) = reference else { - return Some(Err(match active { - Some(a) => format!("photo {a} is in Recently Deleted: restore it to use it as the reference"), - None => "select a photo to use as the reference".into(), - })); - }; - app.ui.reference = Some(r); - if app.session.active().is_none_or(|a| a.0 == r) - && let Some(i) = vis.iter().position(|x| x.0 == r) - && let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))) - { - let _ = app.session.execute("library.select", &json!({"ids": [next.0]})); - } - app.ui.view = ViewMode::Reference; - Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) - } - "photo.setReference" => { - // only an absent (or null) `id` means the active photo; any other non-id is refused - let id = match p.get("id").filter(|v| !v.is_null()) { - Some(v) => match v.as_u64() { - Some(id) => Some(id), - None => return Some(Err(format!("photo.setReference: `id` must be a photo id, not {v}"))), - }, - None => app.session.active().map(|a| a.0), - }; - let Some(id) = id else { return Some(Err("select a photo to use as the reference".into())) }; - match app.session.catalog.photo(lightcraft_catalog::PhotoId(id)) { - None => return Some(Err(format!("no photo {id}"))), - Some(p) if p.deleted => return Some(Err(format!("photo {id} is in Recently Deleted: restore it to use it as the reference"))), - Some(_) => {} - } - app.ui.reference = Some(id); - Ok(json!({"reference": id})) - } - "compare.swap" => crate::panels::compare::swap(app), - "compare.set" => { - let Some(id) = p.get("id").and_then(Value::as_u64) else { return Some(Err("compare.set needs `id`".into())) }; - crate::panels::compare::set_photo(app, lightcraft_catalog::PhotoId(id), p.get("side").and_then(Value::as_str)) - } - "compare.makeSelect" => 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" })); - 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 { - app.ui.fullscreen = false; - app.ui.slideshow = None; - } 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.slideshow" => { - // the photos in view, full screen, with the playback settings (see panels::slideshow) - let now = ctx.input(|i| i.time); - let r = crate::panels::slideshow::start(app, p, now); - if r.is_ok() { - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); - } - r - } - "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => { - let now = ctx.input(|i| i.time); - crate::panels::slideshow::command(app, id, now) - } - "view.slideshowOptions" => { - app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); - Ok(Value::Null) - } - "view.fullScreenPreview" => { - app.ui.fullscreen = !app.ui.fullscreen; - if !app.ui.fullscreen { - app.ui.slideshow = None; - } - if app.ui.fullscreen { - app.ui.zoom = Zoom::Fit; - app.ui.tool.clear(); - let _ = app.session.end_interaction(); - } - Ok(json!({"fullscreen": app.ui.fullscreen})) - } - "view.lightsOut" => { - // {mode?: on | dim | off} (cycles when omitted) - match p.get("mode").and_then(Value::as_str) { - Some(m) => serde_json::from_value(json!(m)).map_err(|_| format!("unknown lights-out mode: {m} (on|dim|off)")), - None => Ok(app.ui.lights_out.next()), - } - .map(|mode| { - app.ui.lights_out = mode; - json!({"lightsOut": mode}) - }) - } - "view.enterFullScreen" => { - // applied by the host's frame logic (it knows the window's current state) - let on = p.get("on").and_then(Value::as_bool); - app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); - Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) - } - "view.infoOverlay" => { - app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { - Some(m) => match serde_json::from_value(json!(m)) { - Ok(v) => v, - Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), - }, - None => app.ui.info_overlay.next(), - }; - let label = match app.ui.info_overlay { - crate::state::InfoOverlay::Off => "Info Overlay Off", - crate::state::InfoOverlay::Basic => "Info Overlay: File", - crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", - }; - app.toast(&ctx, crate::i18n::tr(label)); - Ok(json!({"infoOverlay": app.ui.info_overlay})) - } - "view.viewOptions" => { - // `info1` / `info2` set the lines for an agent; without them the window opens - let mut info = app.ui.settings.loupe_info; - let mut set = false; - 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 set { - app.ui.settings.loupe_info = info; - } else { - app.ui.dialog = Some(Dialog::ViewOptions); - } - Ok(json!({"loupeInfo": app.ui.settings.loupe_info})) - } - "view.chooseLayoutImage" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(path) => path.trim().to_string(), - None => { - let req = PickRequest::file(crate::i18n::tr("Choose Layout Image…"), "PNG", &["png"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(paths) => match paths.first() { - Some(path) => path.trim().to_string(), - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform; supply path".into())), - } - } - }; - if path.is_empty() { - return Some(Err("view.chooseLayoutImage: path is empty".into())); - } - let li = &mut app.ui.layout_image; - li.path = path; - li.show = true; - li.revision = li.revision.wrapping_add(1); - app.toast(&ctx, "Layout Image On"); - Ok(json!({"layoutImage": app.ui.layout_image})) - } - "view.layoutImage" => { - let show = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.layout_image.show); - // nothing chosen yet: showing it chooses one first - if show && app.ui.layout_image.path.trim().is_empty() { - return run_ui_command(app, "view.chooseLayoutImage", &json!({})); - } - app.ui.layout_image.show = show; - app.toast(&ctx, if show { "Layout Image On" } else { "Layout Image Off" }); - Ok(json!({"layoutImage": app.ui.layout_image})) - } - "view.layoutImageOpacity" | "view.layoutImageMatte" => { - let key = if id == "view.layoutImageOpacity" { "opacity" } else { "matte" }; - let Some(v) = p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) else { - return Some(Err(format!("{id}: {key} (0..1) is required"))); - }; - let v = v.clamp(0.0, 1.0) as f32; - if key == "opacity" { - app.ui.layout_image.opacity = v; - } else { - app.ui.layout_image.matte = v; - } - Ok(json!({"layoutImage": app.ui.layout_image})) - } - "view.navigator" => { - app.ui.navigator = !app.ui.navigator; - Ok(json!({"navigator": app.ui.navigator})) - } - "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) { - let tabs: Vec<&str> = crate::panels::settings::TABS.iter().map(|(id, _)| *id).collect(); - return Some(Err(format!("unknown settings tab `{tab}` ({})", tabs.join("|")))); - } - app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); - Ok(Value::Null) - } - "app.openLibrary" => crate::panels::settings::open_library(app, p), - "app.newLibrary" => crate::panels::settings::new_library(app, p), - "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), - "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), - "app.displayProfile" => crate::panels::settings::display_profile(app, p), - "file.backupLibrary" | "file.restoreLibrary" => { - let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; - match action { - Some(f) => f(&mut app.session), - None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()), - } - } - "view.filmstrip" => { - app.ui.filmstrip = !app.ui.filmstrip; - Ok(Value::Null) - } - "view.frameRate" => { - // {show?: bool} (toggles when omitted) - app.ui.frame_rate = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.frame_rate); - Ok(json!({"show": app.ui.frame_rate})) - } - "view.leftPanel" => { - app.ui.left_panel = !app.ui.left_panel; - Ok(Value::Null) - } - "view.rightPanel" => { - // Classic's F8: hide the right panel, and bring back the same one next time (Edit when - // none was hidden). Hiding a tool ends it, as closing its panel does. - if app.ui.right != RightPanel::None && crate::docking::exposed(app, crate::docking::Panel::Tools) { - app.ui.right_hidden = app.ui.right; - app.ui.right = RightPanel::None; - if app.ui.tool != "wbPicker" { - app.ui.tool.clear(); - } - let _ = app.session.end_interaction(); - } else { - let p = match std::mem::take(&mut app.ui.right_hidden) { - RightPanel::None => RightPanel::Edit, - p => p, - }; - app.ui.right = p; - if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - if p == RightPanel::Remove && app.ui.tool.is_empty() { - app.ui.tool = "remove".into(); - } - } - Ok(json!({"open": app.ui.right != RightPanel::None})) - } - "view.beforeAfter" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; - Ok(Value::Null) - } - "view.beforeAfterSplit" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::Split { BeforeAfter::Off } else { BeforeAfter::Split }; - Ok(Value::Null) - } - "view.beforeAfterTopBottom" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::TopBottom { BeforeAfter::Off } else { BeforeAfter::TopBottom }; - Ok(Value::Null) - } - "view.beforeAfterSplitTopBottom" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::SplitTopBottom { BeforeAfter::Off } else { BeforeAfter::SplitTopBottom }; - Ok(Value::Null) - } - "view.showOriginal" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original }; - Ok(Value::Null) - } - "view.zoomFit" => { - app.ui.zoom = Zoom::Fit; - Ok(Value::Null) - } - "view.zoom100" => { - app.ui.zoom = Zoom::Percent(100.0); - Ok(Value::Null) - } - "view.zoomToggle" => { - // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus - // point when the photo has one (View ▸ Zoom to Focus Point) - let zoom_in = app.ui.zoom == Zoom::Fit; - app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; - app.ui.zoom_anim = true; - if zoom_in - && app.ui.settings.zoom_to_focus - && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) - { - app.ui.pan = pan; - return Some(Ok(json!({"focus": [pan.0, pan.1]}))); - } - Ok(Value::Null) - } - "view.clickZoom" => { - // {ratio?: 1|2|3|4|8} → {ratio} - if let Some(r) = p.get("ratio").and_then(Value::as_f64) { - let pct = (r * 100.0).round() as u32; - if !crate::state::CLICK_ZOOMS.contains(&pct) { - return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)"))); - } - app.ui.click_zoom = pct; - } - Ok(json!({"ratio": app.ui.click_zoom / 100})) - } - "view.zoomIn" | "view.zoomOut" => { - let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0]; - let cur = match app.ui.zoom { - Zoom::Percent(p) => p, - _ => 25.0, - }; - let next = if id == "view.zoomIn" { - steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0) - } else { - steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0) - }; - app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; - Ok(Value::Null) - } - "view.thumbSizeUp" | "view.thumbSizeDown" => { - // the bottom-bar slider's range, one `THUMB_SIZE_STEP` per call; {} → {size} - if !thumb_size_view(app) { - return Some(Err(format!("{id}: only in Photo Grid, Square Grid or People"))); - } - let step = if id == "view.thumbSizeUp" { crate::state::THUMB_SIZE_STEP } else { -crate::state::THUMB_SIZE_STEP }; - app.ui.thumb_size = (app.ui.thumb_size + step).clamp(crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); - Ok(json!({"size": app.ui.thumb_size})) - } - "view.navigate" => { - // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). - // Validate the complete request before changing either part of the viewport. - let zoom = match p.get("zoom") { - Some(v) => match serde_json::from_value::(v.clone()) { - Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => { - return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into())); - } - Ok(z) => z, - Err(e) => return Some(Err(format!("view.navigate: {e}"))), - }, - None => app.ui.zoom, - }; - let pan = match p.get("pan") { - Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) { - Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y), - _ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())), - }, - None => app.ui.pan, - }; - app.ui.zoom = zoom; - app.ui.pan = pan; - app.ui.zoom_anim = false; - Ok(json!({"zoom": zoom, "pan": pan})) - } - "view.clipping" => { - app.ui.show_clipping = !app.ui.show_clipping; - Ok(Value::Null) - } - "view.visualizeHdr" => { - // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`). - app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize); - Ok(json!({"on": app.ui.hdr_visualize})) - } - "view.softProof" => { - // {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.histogram" => { - app.ui.histogram = !app.ui.histogram; - Ok(Value::Null) - } - "view.maskOverlay" => { - app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); - Ok(json!({"maskOverlay": app.ui.mask_overlay})) - } - "view.maskOverlayAll" => { - app.ui.mask_overlay_all = !app.ui.mask_overlay_all; - Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all})) - } - "view.maskOverlayMode" => { - use lightcraft_engine::pipeline::MaskView; - let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); - let next = match p.get("mode").and_then(Value::as_str) { - Some(m) => match MaskView::parse(m) { - Some(v) => v, - None => { - let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); - return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); - } - }, - None => cur.next(), - }; - app.ui.mask_overlay_mode = next.name().into(); - app.ui.mask_overlay = true; - app.toast(&ctx, next.label()); - Ok(json!({"mode": next.name()})) - } - "view.maskOverlayColor" => { - // no params: the next of the panel's swatch colours - if p.get("color").is_none() && p.get("opacity").is_none() { - let all = crate::panels::masking::OVERLAY_COLORS; - let i = all.iter().position(|c| *c == app.ui.mask_overlay_color).map_or(0, |i| (i + 1) % all.len()); - app.ui.mask_overlay_color = all[i]; - } - if let Some(c) = p.get("color") { - match parse_rgb(c) { - Some(rgb) => app.ui.mask_overlay_color = rgb, - None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), - } - } - if let Some(o) = p.get("opacity").and_then(Value::as_f64) { - app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; - } - let [r, g, b] = app.ui.mask_overlay_color; - Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) - } - "brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => { - let (k, df) = match id { - "brush.smaller" => (1.0 / 1.2, 0.0), - "brush.larger" => (1.2, 0.0), - "brush.featherLess" => (1.0, -10.0), - _ => (1.0, 10.0), - }; - Ok(adjust_brush(app, k, df)) - } - "view.maskPins" => { - app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); - Ok(json!({"maskPins": app.ui.mask_pins})) - } - "view.toolOverlay" => { - use crate::state::ToolOverlay; - let mode = match p.get("mode") { - None | Some(Value::Null) => app.ui.tool_overlay.next(), - Some(v) => match v.as_str().and_then(ToolOverlay::parse) { - Some(m) => m, - None => return Some(Err(format!("view.toolOverlay: unknown mode {v} (auto|always|selected|never)"))), - }, - }; - app.ui.tool_overlay = mode; - app.toast(&ctx, tool_overlay_toast(mode)); - Ok(json!({"mode": mode.name()})) - } - "view.visualizeSpots" => { - // like Lightroom's A: opens the Remove tool with the view on, or toggles it there - if app.ui.right == RightPanel::Remove { - app.ui.visualize_spots = !app.ui.visualize_spots; - } else { - app.ui.right = RightPanel::Remove; - app.ui.visualize_spots = true; - } - Ok(Value::Null) - } - // A under the Crop panel: the aspect lock (the panel's button runs the same toggle) - "crop.lockAspect" => app.run("crop.aspect", json!({"aspect": "toggle"})), - "view.cropOverlay" => { - use crate::state::CropOverlay::*; - app.ui.crop_overlay = match app.ui.crop_overlay { - Thirds => Grid, - Grid => Golden, - Golden => Diagonal, - Diagonal => Triangle, - Triangle => Spiral, - Spiral => None, - None => Thirds, - }; - Ok(json!({"overlay": app.ui.crop_overlay})) - } - "view.cropOverlayOrientation" => { - app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4; - Ok(json!({"orientation": app.ui.crop_overlay_orient})) - } - "local.addRoot" => { - // a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes - let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) }; - let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into()); - let abs = if abs.is_empty() { path.to_string() } else { abs }; - if !std::path::Path::new(&abs).is_dir() { - return Some(Err(format!("{path}: not a folder"))); - } - use crate::panels::left::same_folder; - if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) { - app.ui.local_roots.push(abs.clone()); - } - app.ui.hidden_locations.retain(|h| !same_folder(h, &abs)); - Ok(json!({"roots": app.ui.local_roots})) - } - "local.hide" => match p.get("path").and_then(Value::as_str) { - Some(path) => { - if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) { - app.ui.hidden_locations.push(path.to_string()); - } - Ok(json!({"hidden": app.ui.hidden_locations})) - } - None => Err("local.hide needs a path".into()), - }, - "local.restoreHidden" => { - // one path, or (no path) every hidden location - match p.get("path").and_then(Value::as_str) { - Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)), - None => app.ui.hidden_locations.clear(), - } - Ok(json!({"hidden": app.ui.hidden_locations})) - } - "view.soloSections" => bool_param(id, p, "on").map(|on| { - app.ui.set_single_panel(on.unwrap_or(!app.ui.single_panel)); - json!({"soloSections": app.ui.single_panel}) - }), - "view.filterBar" => { - app.ui.filter_bar = !app.ui.filter_bar; - if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { - app.ui.view = ViewMode::PhotoGrid; - } - Ok(json!({"filterBar": app.ui.filter_bar})) - } - "panel.edit" => { - panel(app, &ctx, RightPanel::Edit, "Edit"); - Ok(Value::Null) - } - "panel.profiles" => { - // toggles between the profile browser and the Edit panel it belongs to - app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) { - RightPanel::Edit - } else { - RightPanel::Profiles - }; - if !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - Ok(json!({"open": app.ui.right == RightPanel::Profiles})) - } - "panel.crop" => { - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); - Ok(Value::Null) - } - "panel.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"); - Ok(Value::Null) - } - "panel.redeye" => { - panel(app, &ctx, RightPanel::RedEye, "Red Eye"); - Ok(Value::Null) - } - "panel.info" => { - panel(app, &ctx, RightPanel::Info, "Info"); - Ok(Value::Null) - } - "panel.keywords" => { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); - Ok(Value::Null) - } - "panel.versions" => { - panel(app, &ctx, RightPanel::Versions, "Versions"); - Ok(Value::Null) - } - "panel.activity" => { - panel(app, &ctx, RightPanel::Activity, "History"); - Ok(Value::Null) - } - "panel.presets" => { - app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets); - if app.ui.presets && app.ui.view != ViewMode::Detail { - app.ui.view = ViewMode::Detail; - } - Ok(Value::Null) - } - "tool.done" => { - // Return commits a tool panel (crop, remove, red eye, masking): back to Edit - use RightPanel::*; - if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) { - let _ = app.session.end_interaction(); - app.ui.tool.clear(); - app.ui.right = Edit; - } - Ok(Value::Null) - } - "panel.close" => { - app.ui.right = RightPanel::None; - app.ui.presets = false; - Ok(Value::Null) - } - 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) { - return Err(format!("{s}: no such Edit section")); - } - let sec = &s["section.".len()..]; - if app.ui.right != RightPanel::Edit { - app.ui.right = RightPanel::Edit; - } - if solo == Some(true) { - app.ui.solo_section(sec); - } else { - app.ui.toggle_section(sec); - } - Ok(Value::Null) - }), - s if s.starts_with("tool.") => { - let tool = &s["tool.".len()..]; - match tool { - "none" => app.ui.tool.clear(), - "guidedUpright" => { - // Crop & Geometry with Guided Upright on, ready to draw guides - app.ui.right = RightPanel::Crop; - app.ui.view = ViewMode::Detail; - let guided = app - .session - .active() - .and_then(|id| app.session.develop_of(id)) - .is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided); - if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) { - return Some(Err(e)); - } - app.ui.tool = "guidedUpright".into(); - } - "brush" => { - // A new mask and Add/Subtract are selection operations, not the brush's - // Erase mode. Paint positively into a component with the requested op. - let create = if p.get("new").and_then(Value::as_bool) == Some(true) { - Some(app.run("mask.add", json!({"kind": "brush"}))) - } else { - p.get("op").map(|op| app.run("mask.addComponent", json!({"kind": "brush", "op": op}))) - }; - if let Some(result) = create { - if let Err(e) = result { - return Some(Err(e)); - } - app.ui.brush_erase = false; - app.ui.mask_overlay = true; - } - app.ui.right = RightPanel::Masking; - app.ui.view = ViewMode::Detail; - app.ui.tool = "brush".into(); - } - "linear" | "radial" => { - app.ui.right = RightPanel::Masking; - app.ui.view = ViewMode::Detail; - app.ui.tool = tool.into(); - return Some(app.session.execute("mask.add", &json!({"kind": tool})).map_err(|e| e.to_string())); - } - "wbPicker" => { - app.ui.right = RightPanel::Edit; - app.ui.view = ViewMode::Detail; - app.ui.tool = "wbPicker".into(); - } - other => return Some(Err(format!("unknown tool `{other}`"))), - } - Ok(Value::Null) - } - "dialog.newFolder" => { - app.ui.dialog = Some(Dialog::NewAlbum { - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - folder: true, - parent: parent_param(p), - include_selected: false, - }); - Ok(Value::Null) - } - "dialog.newAlbum" => { - // {name?, parent?, includeSelected? (true)} - app.ui.dialog = Some(Dialog::NewAlbum { - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - folder: false, - parent: parent_param(p), - include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true), - }); - Ok(Value::Null) - } - "dialog.autoStack" => { - app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); - Ok(Value::Null) - } - "dialog.allMetadata" => { - let r = match app.session.execute("photo.allMetadata", p) { - Ok(r) => r, - Err(e) => return Some(Err(e.to_string())), - }; - let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default(); - app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() }); - Ok(Value::Null) - } - "dialog.smartAlbum" => { - // {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.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) - } - "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 - .session - .active() - .and_then(|id| app.session.catalog.photo(id)) - .map(|p| p.date()) - .filter(|d| lightcraft_catalog::dates::shift_iso(d, 0).is_some()) - .map(|d| d.replace('T', " ")) - .unwrap_or_default(); - let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); - app.ui.dialog = - Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); - Ok(Value::Null) - } - "dialog.labelNames" => { - let names = - lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect(); - app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() }); - Ok(Value::Null) - } - "dialog.rename" => { - let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); - app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); - Ok(Value::Null) - } - "dialog.createPreset" => { - app.ui.dialog = Some(Dialog::create_preset()); - Ok(Value::Null) - } - "dialog.pasteSettings" => { - let groups = - app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); - app.ui.dialog = Some(Dialog::PasteSettings { groups }); - Ok(Value::Null) - } - "view.focusSearch" => { - // the search field lives in the top bar of the library and detail views alike - app.ui.focus_search = true; - Ok(Value::Null) - } - "dialog.copySettings" => { - let groups = - app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); - app.ui.dialog = Some(Dialog::CopySettings { groups }); - Ok(Value::Null) - } - "dialog.slideshowExport" => { - let format = match p.get("format").and_then(Value::as_str) { - Some("jpeg") => lightcraft_engine::slideshow_export::Format::Jpeg, - Some("video") => lightcraft_engine::slideshow_export::Format::Video, - _ => lightcraft_engine::slideshow_export::Format::Pdf, - }; - app.ui.dialog = Some(Dialog::SlideshowExport { settings: lightcraft_engine::slideshow_export::ExportSettings::for_format(format) }); - Ok(Value::Null) - } - "app.slideshowExport" => crate::slideshow_export::start(app, id, p), - "dialog.contactSheet" => { - app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); - Ok(Value::Null) - } - "dialog.print" => { - app.ui.print = Default::default(); - app.ui.dialog = Some(Dialog::Print); - Ok(Value::Null) - } - "app.print" => { - if app.export.is_some() { - return Some(Err("an export is already running".into())); - } - let mut params = p.clone(); - if p.get("path").is_none() { - let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); - let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), - }; - let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; - params["path"] = json!(path); - } - crate::export_task::start_print(app, ¶ms) - } - "app.contactSheet" => { - if app.export.is_some() { - return Some(Err("an export is already running".into())); - } - let mut params = p.clone(); - if p.get("path").is_none() { - let req = PickRequest::save("Contact Sheet PDF", "PDF", &["pdf"], "Contact Sheet.pdf"); - let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), - }; - let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; - params["path"] = json!(path); - } - crate::export_task::start_contact_sheet(app, ¶ms) - } - "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); - let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); - // stored settings: a depth the format lacks (saved before #1952) opens at the format's own - let opts = lightcraft_engine::export::ExportOptions::from_json(&lightcraft_engine::export::ExportOptions::known_keys_only(&prev)); - // no previous export: 2048 px long edge; a previous full-size export: full size - let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); - let resize = opts.resize.unwrap_or_default(); - app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, preset_name: String::new(), limit_kb: u("limitKb", 0) as u32, dir }); - Ok(Value::Null) - } - "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), - "merge.hdrBatch" => crate::merge::start_batch(app, p), - "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), - "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), - "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), - "dialog.faceModel" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => app.services.pick_model_file.as_mut().and_then(|f| f().into_iter().next()), - }; - match path { - Some(path) => crate::panels::faces::open_dialog(app, &path), - None => Ok(Value::Null), - } - } - "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), - "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), - "app.openSamModelFolder" => crate::panels::dialogs::show_model_folder(app), - "app.about" => { - app.ui.dialog = Some(Dialog::About); - Ok(Value::Null) - } - "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 }); - Ok(Value::Null) - } - "app.whatsNew" => { - app.ui.dialog = Some(Dialog::WhatsNew); - Ok(json!({"text": crate::panels::dialogs::WHATS_NEW})) - } - "app.systemInfo" => { - 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.shortcuts" => { - app.ui.dialog = Some(Dialog::Shortcuts); - Ok(Value::Null) - } - "app.setShortcut" => crate::shortcuts::set_shortcut(app, p), - "app.resetShortcuts" => { - app.ui.settings.keymap.clear(); - Ok(Value::Null) - } - "library.browse" if !cfg!(target_arch = "wasm32") => { - if crate::lightroom_import::is_running(app) { - return Some(Err("finish Lightroom catalog import before browsing folders".into())); - } - // listed and read in the background (see `import::browse`) - let path = p.get("path").and_then(Value::as_str)?; - crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) - } - "file.addPhotos" => { - if crate::lightroom_import::is_running(app) { - return Some(Err("finish Lightroom catalog import before adding photos".into())); - } - 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(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // review first: the import dialog lists what was found - let r = crate::import::open(app, paths); - import_into_album(app, p, r) - } - "file.importLightroom" => { - // where the catalog goes (#2672): checked before a file dialog opens - let req = match crate::lightroom_destination::Request::parse(p) { - Ok(req) => req, - Err(e) => return Some(Err(e)), - }; - let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { - Some(path) => Some(path), - None => { - let req = - PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => crate::lightroom_destination::import(app, &path, &req), - None => Ok(Value::Null), - } - } - "app.quit" => { - app.ui.quit = true; - Ok(Value::Null) - } - "dialog.saveMetadataPreset" => { - // from the active photo's copyright, creator and place - crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name"); - Ok(Value::Null) - } - "file.findMissing" => { - 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.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) { - Some(x) => x.to_string(), - None => match lightcraft_engine::devices::devices_now().into_iter().next() { - Some(d) => d.path, - None => return Some(Err("no camera or memory card found".into())), - }, - }; - let r = crate::import::open(app, vec![path]); - // only the scan just started (an error means another scan is running) - if r.is_ok() - && let Some(t) = &mut app.scan - { - t.copy = true; - } - import_into_album(app, p, r) - } - "file.addFolder" => { - // a folder (searched recursively) into the import review - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |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, - }, - }; - match path { - Some(path) => { - let r = crate::import::open(app, vec![path]); - import_into_album(app, p, r) - } - None => 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); - } - "dialog.saveMaskEffect" => { - if app.session.active_mask.is_none() { - return Some(Err("select a mask first".into())); - } - crate::panels::mask_effects::open_save(app); - Ok(Value::Null) - } - "dialog.manageMaskEffects" => { - crate::panels::mask_effects::open_manage(app); - Ok(Value::Null) - } - "file.exportMaskEffects" => { - 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.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) { - Some(x) => Some(x.to_string()), - None => { - let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); - let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], 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}); - if let Some(g) = group { - params["group"] = json!(g); - } - if let Some(ids) = p.get("ids") { - params["ids"] = ids.clone(); - } - let r = app.session.execute("preset.export", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); - } - "file.importCurvePresets" => { - 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); - } - "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)), - "app.openLogFolder" => open_log_folder(app), - "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { - let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); - crate::links::open(app, url) - } - "app.exportPrevious" => match app.session.last_export.clone() { - Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)), - None => Err("nothing exported yet — use Export…".into()), - }, - _ => return None, - }; - Some(r) -} - /// Views whose bottom bar has the thumbnail-size slider and that draw tiles of that size. fn thumb_size_view(app: &LightcraftApp) -> bool { matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid | ViewMode::People) @@ -2326,264 +119,3 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { } v } - -/// 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 -/// says so. True when the key was handled here, false outside albums (then it deletes the photos -/// from the library). Photo ▸ Delete Photo still deletes from the library everywhere. -pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { - let lightcraft_engine::LibrarySource::Album(aid) = app.session.source else { return false }; - let Some(album) = app.session.catalog.album(aid) else { return false }; - if album.is_smart() { - app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo")); - return true; - } - if app.session.selected().is_empty() { - return true; - } - match app.run("album.removePhotos", json!({"id": aid.0})) { - Ok(r) => { - let n = r["removed"].as_u64().unwrap_or(0); - let name = r["album"].as_str().unwrap_or_default().to_string(); - let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); - app.toast(ctx, crate::i18n::tr_format!("Removed {what} from {name}", what = what, name = name)); - } - Err(e) => app.toast(ctx, e), - } - true -} - -/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead -/// of deleting; true when it did (the dialog's OK runs `photo.delete`). -pub fn confirm_delete(app: &mut LightcraftApp) -> bool { - if !app.ui.settings.confirm_delete { - return false; - } - let count = app.session.selected().len(); - if count == 0 { - return false; - } - app.ui.dialog = Some(Dialog::ConfirmDelete { count }); - true -} - -/// Platform-appropriate label for revealing a file in the system file manager. -pub fn reveal_label() -> &'static str { - if cfg!(target_os = "macos") { - "Show in Finder" - } else if cfg!(target_os = "windows") { - "Show in Explorer" - } else { - "Show in File Manager" - } -} - -/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be -/// attached to a report without hunting for the settings folder (#260). -fn open_log_folder(app: &mut LightcraftApp) -> Result { - let path = app.services.log_file.clone().ok_or("this session keeps no log file")?; - let reveal = app.services.reveal.as_mut().ok_or("not available here")?; - reveal(&path)?; - Ok(json!({"path": path})) -} - -/// Reveal the active photo's original in the system file manager. -fn show_in_finder(app: &mut LightcraftApp) -> Result { - let id = app.session.active().ok_or("no photo selected")?; - let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) { - Some(lightcraft_engine::catalog::Source::File { path }) => path, - _ => return Err("this photo has no file (demo scene)".into()), - }; - let reveal = app.services.reveal.as_mut().ok_or("not available here")?; - reveal(&path)?; - Ok(json!({"path": path})) -} - -/// The folder the active photo was imported from, as the sidebar's Folders section counts it (a -/// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed -/// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show. -fn photo_folder(app: &LightcraftApp, id: Option) -> Option { - let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; - let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None }; - let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; - (!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string()) -} - -/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its -/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to -/// look in, the folder alone decides, and the command still checks. -pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option) -> bool { - let Some(dir) = photo_folder(app, id) else { return false }; - app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some()) -} - -/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id` -/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its -/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the -/// section and the folders above it open, and it scrolls there (`reveal_folder`). -fn show_in_folders(app: &mut LightcraftApp, id: Option) -> Result { - let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?; - let tree = app.caches.folder_tree(&app.session.catalog); - // the row's own spelling (forward slashes): what its click hands `library.source`, and its id - let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?; - app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?; - app.ui.left_panel = true; - app.ui.reveal_folder = Some(path.clone()); - Ok(json!({"path": path})) -} - -/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) -/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the -/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, -/// so a missing editor leaves no copy behind. Once the copies exist, an editor that won't open them -/// is reported in the result's `failed` (and a toast), not as an error. -fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { - // every way in (menu, shortcut, context menu, the control channel) says why nothing opened - edit_in_inner(app, ctx, p, photocraft).inspect_err(|e| app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e))) -} - -fn edit_in_inner(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { - let settings = &app.ui.settings; - let (file, configured) = if photocraft { - let path = settings.photocraft_path.trim(); - (settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() }) - } else { - (settings.external_file, settings.external_editor.clone()) - }; - let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); - if let Some(check) = app.services.check_editor.as_ref() { - check(&editor)?; - } - let mut params = file.params(); - if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { - for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") { - o.insert(k.clone(), v.clone()); - } - } - let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?; - // every selected photo got a copy (#920); each opens in the editor - let copies: Vec<(String, Option)> = r["copies"] - .as_array() - .map(|c| c.iter().map(|c| (c["path"].as_str().unwrap_or_default().to_string(), c["id"].as_u64())).collect()) - .unwrap_or_else(|| vec![(r["path"].as_str().unwrap_or_default().to_string(), r["id"].as_u64())]); - let mut failed = None; - for (path, id) in &copies { - if let Some(id) = id { - app.ui.external_edits.push(*id); - } - // the copy is LightCraft's own render, but it goes through the same gate as any other - // path handed to the system opener (#2135) - match lightcraft_engine::open_path::openable(path, "") { - Err(e) => failed = Some(e), - Ok(absolute) => { - if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { - failed = Some(e); - } - } - } - } - let mut r = r; - if let Some(o) = r.as_object_mut() { - o.insert("failed".into(), json!(failed)); - } - if let Some(e) = failed { - // the copies are in the library already (one undo step): an error would invite a retry - // that makes another set, so the result says what failed and the toast says why - app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); - return Ok(r); - } - match copies.as_slice() { - [(path, _)] => { - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); - } - _ => app.toast(ctx, crate::i18n::tr_format!("{n} photos opened for editing; each is stacked with its original", n = copies.len())), - } - Ok(r) -} - -/// Most originals one Open Original hands the editor (each starts it once). -const MAX_ORIGINALS: usize = 50; - -/// Photo ▸ Edit In ▸ Open Original in Other Editor: hand each selected photo's original file -/// itself (a raw stays a raw: nothing is rendered, copied or added to the library) to the editor -/// set in Settings ▸ External Editing, e.g. a raw developer. `p` may give `ids` and the editor -/// (`app`). Demo photos and missing files are skipped and listed; when no photo has a file to -/// open, it fails saying why. -fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result { - let r = open_originals(app, p); - if let Err(e) = &r { - app.toast(ctx, e.clone()); - } - let r = r?; - let opened = r["opened"].as_array().map_or(0, Vec::len); - match (r["failed"].as_str(), r["opened"][0]["path"].as_str()) { - (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), - (None, Some(path)) if opened == 1 => { - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(ctx, crate::i18n::tr_format!("{name} opened in the other editor", name = name)); - } - _ => app.toast(ctx, crate::i18n::tr_format!("{n} originals opened in the other editor", n = opened)), - } - Ok(r) -} - -fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { - let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; - ids.dedup(); - if ids.is_empty() { - return Err("no photo selected".into()); - } - if ids.len() > MAX_ORIGINALS { - return Err(format!("{} photos selected: open at most {MAX_ORIGINALS} originals at a time", ids.len())); - } - let mut files = Vec::new(); - let mut skipped = Vec::new(); - for id in ids { - // the path may come from an imported catalog: only a photo file LightCraft reads is - // handed to the opener, never a program (#2135) - match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { - Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { - Ok(absolute) => files.push((id, absolute)), - Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), - }, - Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), - None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), - } - } - if files.is_empty() { - let why = skipped.first().and_then(|s| s["reason"].as_str()).unwrap_or("no original file"); - return Err(if skipped.len() == 1 { - format!("nothing to open: {why}") - } else { - format!("nothing to open: none of these photos has its original file ({why})") - }); - } - let editor = p.get("app").and_then(Value::as_str).map_or_else(|| app.ui.settings.external_editor.clone(), str::to_string); - if let Some(check) = app.services.check_editor.as_ref() { - check(&editor)?; - } - // all at once where the host can: an editor that keeps one window (DxO PhotoLab) loses the - // files that arrive while it is still starting - if let Some(open_all) = app.services.open_all_with.as_mut() { - let paths: Vec = files.iter().map(|(_, path)| path.clone()).collect(); - open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?; - let opened: Vec = files.iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect(); - return Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": null})); - } - let open = app.services.open_with.as_mut().ok_or("opening another application isn't available here")?; - let mut opened = Vec::new(); - let mut failed = None; - for (id, path) in files { - match open(&path, &editor) { - Ok(()) => opened.push(json!({"id": id.0, "path": path})), - Err(e) => failed = Some(e), - } - } - if opened.is_empty() - && let Some(e) = failed - { - return Err(format!("Couldn't open the editor: {e}")); - } - Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": failed})) -} diff --git a/crates/ui-egui/src/menus/files.rs b/crates/ui-egui/src/menus/files.rs new file mode 100644 index 000000000..52389d930 --- /dev/null +++ b/crates/ui-egui/src/menus/files.rs @@ -0,0 +1,262 @@ +use super::*; + +/// 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 +/// says so. True when the key was handled here, false outside albums (then it deletes the photos +/// from the library). Photo ▸ Delete Photo still deletes from the library everywhere. +pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + let lightcraft_engine::LibrarySource::Album(aid) = app.session.source else { return false }; + let Some(album) = app.session.catalog.album(aid) else { return false }; + if album.is_smart() { + app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo")); + return true; + } + if app.session.selected().is_empty() { + return true; + } + match app.run("album.removePhotos", json!({"id": aid.0})) { + Ok(r) => { + let n = r["removed"].as_u64().unwrap_or(0); + let name = r["album"].as_str().unwrap_or_default().to_string(); + let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); + app.toast(ctx, crate::i18n::tr_format!("Removed {what} from {name}", what = what, name = name)); + } + Err(e) => app.toast(ctx, e), + } + true +} + +/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead +/// of deleting; true when it did (the dialog's OK runs `photo.delete`). +pub fn confirm_delete(app: &mut LightcraftApp) -> bool { + if !app.ui.settings.confirm_delete { + return false; + } + let count = app.session.selected().len(); + if count == 0 { + return false; + } + app.ui.dialog = Some(Dialog::ConfirmDelete { count }); + true +} + +/// Platform-appropriate label for revealing a file in the system file manager. +pub fn reveal_label() -> &'static str { + if cfg!(target_os = "macos") { + "Show in Finder" + } else if cfg!(target_os = "windows") { + "Show in Explorer" + } else { + "Show in File Manager" + } +} + +/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be +/// attached to a report without hunting for the settings folder (#260). +pub(super) fn open_log_folder(app: &mut LightcraftApp) -> Result { + let path = app.services.log_file.clone().ok_or("this session keeps no log file")?; + let reveal = app.services.reveal.as_mut().ok_or("not available here")?; + reveal(&path)?; + Ok(json!({"path": path})) +} + +/// Reveal the active photo's original in the system file manager. +pub(super) fn show_in_finder(app: &mut LightcraftApp) -> Result { + let id = app.session.active().ok_or("no photo selected")?; + let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) { + Some(lightcraft_engine::catalog::Source::File { path }) => path, + _ => return Err("this photo has no file (demo scene)".into()), + }; + let reveal = app.services.reveal.as_mut().ok_or("not available here")?; + reveal(&path)?; + Ok(json!({"path": path})) +} + +/// The folder the active photo was imported from, as the sidebar's Folders section counts it (a +/// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed +/// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show. +fn photo_folder(app: &LightcraftApp, id: Option) -> Option { + let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; + let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None }; + let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; + (!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string()) +} + +/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its +/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to +/// look in, the folder alone decides, and the command still checks. +pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option) -> bool { + let Some(dir) = photo_folder(app, id) else { return false }; + app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some()) +} + +/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id` +/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its +/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the +/// section and the folders above it open, and it scrolls there (`reveal_folder`). +pub(super) fn show_in_folders(app: &mut LightcraftApp, id: Option) -> Result { + let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?; + let tree = app.caches.folder_tree(&app.session.catalog); + // the row's own spelling (forward slashes): what its click hands `library.source`, and its id + let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?; + app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?; + app.ui.left_panel = true; + app.ui.reveal_folder = Some(path.clone()); + Ok(json!({"path": path})) +} + +/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) +/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the +/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, +/// so a missing editor leaves no copy behind. Once the copies exist, an editor that won't open them +/// is reported in the result's `failed` (and a toast), not as an error. +pub(super) fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { + // every way in (menu, shortcut, context menu, the control channel) says why nothing opened + edit_in_inner(app, ctx, p, photocraft).inspect_err(|e| app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e))) +} + +fn edit_in_inner(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { + let settings = &app.ui.settings; + let (file, configured) = if photocraft { + let path = settings.photocraft_path.trim(); + (settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() }) + } else { + (settings.external_file, settings.external_editor.clone()) + }; + let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; + } + let mut params = file.params(); + if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { + for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") { + o.insert(k.clone(), v.clone()); + } + } + let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?; + // every selected photo got a copy (#920); each opens in the editor + let copies: Vec<(String, Option)> = r["copies"] + .as_array() + .map(|c| c.iter().map(|c| (c["path"].as_str().unwrap_or_default().to_string(), c["id"].as_u64())).collect()) + .unwrap_or_else(|| vec![(r["path"].as_str().unwrap_or_default().to_string(), r["id"].as_u64())]); + let mut failed = None; + for (path, id) in &copies { + if let Some(id) = id { + app.ui.external_edits.push(*id); + } + // the copy is LightCraft's own render, but it goes through the same gate as any other + // path handed to the system opener (#2135) + match lightcraft_engine::open_path::openable(path, "") { + Err(e) => failed = Some(e), + Ok(absolute) => { + if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { + failed = Some(e); + } + } + } + } + let mut r = r; + if let Some(o) = r.as_object_mut() { + o.insert("failed".into(), json!(failed)); + } + if let Some(e) = failed { + // the copies are in the library already (one undo step): an error would invite a retry + // that makes another set, so the result says what failed and the toast says why + app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); + return Ok(r); + } + match copies.as_slice() { + [(path, _)] => { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); + } + _ => app.toast(ctx, crate::i18n::tr_format!("{n} photos opened for editing; each is stacked with its original", n = copies.len())), + } + Ok(r) +} + +/// Most originals one Open Original hands the editor (each starts it once). +const MAX_ORIGINALS: usize = 50; + +/// Photo ▸ Edit In ▸ Open Original in Other Editor: hand each selected photo's original file +/// itself (a raw stays a raw: nothing is rendered, copied or added to the library) to the editor +/// set in Settings ▸ External Editing, e.g. a raw developer. `p` may give `ids` and the editor +/// (`app`). Demo photos and missing files are skipped and listed; when no photo has a file to +/// open, it fails saying why. +pub(super) fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result { + let r = open_originals(app, p); + if let Err(e) = &r { + app.toast(ctx, e.clone()); + } + let r = r?; + let opened = r["opened"].as_array().map_or(0, Vec::len); + match (r["failed"].as_str(), r["opened"][0]["path"].as_str()) { + (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), + (None, Some(path)) if opened == 1 => { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(ctx, crate::i18n::tr_format!("{name} opened in the other editor", name = name)); + } + _ => app.toast(ctx, crate::i18n::tr_format!("{n} originals opened in the other editor", n = opened)), + } + Ok(r) +} + +fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { + let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; + ids.dedup(); + if ids.is_empty() { + return Err("no photo selected".into()); + } + if ids.len() > MAX_ORIGINALS { + return Err(format!("{} photos selected: open at most {MAX_ORIGINALS} originals at a time", ids.len())); + } + let mut files = Vec::new(); + let mut skipped = Vec::new(); + for id in ids { + // the path may come from an imported catalog: only a photo file LightCraft reads is + // handed to the opener, never a program (#2135) + match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { + Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { + Ok(absolute) => files.push((id, absolute)), + Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), + }, + Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), + None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), + } + } + if files.is_empty() { + let why = skipped.first().and_then(|s| s["reason"].as_str()).unwrap_or("no original file"); + return Err(if skipped.len() == 1 { + format!("nothing to open: {why}") + } else { + format!("nothing to open: none of these photos has its original file ({why})") + }); + } + let editor = p.get("app").and_then(Value::as_str).map_or_else(|| app.ui.settings.external_editor.clone(), str::to_string); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; + } + // all at once where the host can: an editor that keeps one window (DxO PhotoLab) loses the + // files that arrive while it is still starting + if let Some(open_all) = app.services.open_all_with.as_mut() { + let paths: Vec = files.iter().map(|(_, path)| path.clone()).collect(); + open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?; + let opened: Vec = files.iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect(); + return Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": null})); + } + let open = app.services.open_with.as_mut().ok_or("opening another application isn't available here")?; + let mut opened = Vec::new(); + let mut failed = None; + for (id, path) in files { + match open(&path, &editor) { + Ok(()) => opened.push(json!({"id": id.0, "path": path})), + Err(e) => failed = Some(e), + } + } + if opened.is_empty() + && let Some(e) = failed + { + return Err(format!("Couldn't open the editor: {e}")); + } + Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": failed})) +} diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menus/menu_level.rs similarity index 90% rename from crates/ui-egui/src/menu_level.rs rename to crates/ui-egui/src/menus/menu_level.rs index 96ff422f4..611812901 100644 --- a/crates/ui-egui/src/menu_level.rs +++ b/crates/ui-egui/src/menus/menu_level.rs @@ -15,8 +15,9 @@ const EDGE: f32 = 6.0; /// How fast resting on a scroll arrow scrolls, in points per second. const SPEED: f32 = 600.0; +/// The egui temp-data id holding the row a menu at `depth` hangs from. fn anchor_id(depth: usize) -> Id { - Id::new(("lc-menu-anchor", depth)) + Id::unique(("lc-menu-anchor", depth)) } /// Remember the row the submenu at `depth` (2 = a top-level menu's submenu) hangs from. It is @@ -76,7 +77,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, rows: impl FnOn let screen = ctx.content_rect(); // The menu frame's margin and stroke around the rows. let frame = ui.spacing().menu_margin.sum().y + 2.0; - let key = ui.id().with(("lc-menu-level", depth)); + let key = ui.scope_id().with(("lc-menu-level", depth)); // Rows' height from the last frame: does this level overflow? let content: Option = ctx.data(|d| d.get_temp(key)); let room = level_room(screen, bar_bottom, depth, anchor(&ctx, depth), content, frame); @@ -89,21 +90,27 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, rows: impl FnOn // No scrollbar: the ▲ / ▼ strips, the wheel and dragging scroll a menu, like native menus. // egui's floating bar is drawn over the rows' right edge, where the shortcut text ends, and in // its wide (hovered) state its background hides the last character (issue #691). - let out = egui::ScrollArea::vertical() + // 0.37's `ScrollAreaState.offset` is private: a scroll requested after layout is applied as + // the next frame's start offset instead of writing the state directly. + let scroll_to = ctx.data_mut(|d| d.remove_temp::(key.with("scroll-to"))); + let mut area = egui::ScrollArea::vertical() .id_salt(("menu-level", depth)) .max_height(height) .min_scrolled_height(height) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .scroll_source(egui::containers::scroll_area::ScrollSource::ALL) - .show(ui, rows); + .scroll_source(egui::containers::scroll_area::ScrollSource::ALL); + if let Some(y) = scroll_to { + area = area.vertical_scroll_offset(y); + } + let out = area.show(ui, rows); let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0); ctx.data_mut(|d| d.insert_temp(key, out.content_size.y)); - crate::widgets::register(&ctx, format!("menu-level:{depth}"), ui.min_rect()); + crate::widgets::register(ui, format!("menu-level:{depth}"), ui.min_rect()); let (Some(up), Some(down)) = (up, down) else { return }; let t = crate::theme::Tokens::get(&ctx); let x = ui.min_rect().x_range(); let max = (out.content_size.y - out.inner_rect.height()).max(0.0); - let offset = out.state.offset.y; + let offset = out.state.clamped_offset().y; let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1); let mut target = offset; for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] { @@ -118,7 +125,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, rows: impl FnOn let id = key.with(dir < 0.0); let r = ui.interact(strip, id, Sense::hover()); r.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" })) + egui::WidgetInfo::labeled(egui::Role::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" })) }); if live && pointer_on(ui, strip) { target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max); @@ -132,10 +139,9 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, rows: impl FnOn } } } - if target != offset { - let mut st = out.state; - st.offset.y = target; - st.store(&ctx, out.id); + if (target - offset).abs() > 0.01 { + ctx.data_mut(|d| d.insert_temp(key.with("scroll-to"), target)); + ctx.request_repaint(); } } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menus/menubar.rs similarity index 53% rename from crates/ui-egui/src/menubar.rs rename to crates/ui-egui/src/menus/menubar.rs index 30a3b474b..5c1bcb9fc 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menus/menubar.rs @@ -322,6 +322,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "app.systemInfo", "app.openLogFolder", "---", + "app.checkForUpdates", "app.about", ], ), @@ -941,6 +942,54 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result NativeCommand { + if id == "app.quit" { + return NativeCommand::Quit; + } + if crate::panels::notices::blocks_input(app) || crate::panels::library_problem::blocks_dialog_input(app) { + return NativeCommand::Blocked; + } + NativeCommand::Run +} + +/// Run one command resolved from a native menu event under [`native_command_policy`]. +/// +/// The host calls this for every queued native command, so a macOS accelerator, click or About/ +/// Settings item respects the notice and blocking-library barriers. Ordinary dialogs retain +/// their existing native-menu behavior. +pub fn run_native_command(app: &mut LightcraftApp, id: &str, params: Value) -> NativeCommand { + let policy = native_command_policy(app, id); + match policy { + NativeCommand::Run => { + let _ = run_item(app, id, params); + } + NativeCommand::Blocked => log::debug!("native menu: {id} is blocked while another window owns input"), + NativeCommand::Quit => {} + } + policy +} + /// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere. pub fn shortcut_text(sc: &str, mac: bool) -> String { if mac { @@ -1006,7 +1055,7 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 .iter() .map(|(title, _)| { let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false)); - crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect); + crate::widgets::register(ui, format!("menu:{title}"), r.rect); r }) .collect(); @@ -1027,7 +1076,7 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 ui.spacing_mut().item_spacing.x = saved; } else { let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false)); - crate::widgets::register(ui.ctx(), "menu:all", r.rect); + crate::widgets::register(ui, "menu:all", r.rect); egui::Popup::menu(&r).show(|ui| { crate::menu_level::level(ui, 1, bar_bottom, |ui| { for (title, items) in &bar { @@ -1056,7 +1105,7 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 /// Returns the menu whose title was just clicked right after a hover opened it. fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option { let popup = egui::Popup::default_response_id; - let key = egui::Id::new("lc-menubar-hover-opened"); + let key = egui::Id::unique("lc-menubar-hover-opened"); let open = titles.iter().position(|r| egui::Popup::is_id_open(ctx, popup(r))); // the menu a hover opened, until it closes or is clicked let mut confirmed = None; @@ -1095,7 +1144,7 @@ fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option, children: impl FnOnce(&mut egui::Ui)) { let r = crate::widgets::menu_button(ui, text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children)); - crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect); + crate::widgets::register(ui, format!("menusub:{label}"), r.response.rect); if r.inner.is_some() { crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect); } @@ -1121,7 +1170,7 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti b = b.shortcut_text(shortcut_text(sc, mac)); } let r = ui.add_enabled(*enabled, b); - crate::widgets::register(ui.ctx(), format!("menuitem:{id}"), r.rect); + crate::widgets::register(ui, format!("menuitem:{id}"), r.rect); if *checked == Some(true) { let t = crate::theme::Tokens::get(ui.ctx()); let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0)); @@ -1137,766 +1186,4 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti } #[cfg(test)] -mod tests { - use super::*; - - fn app() -> LightcraftApp { - LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) - } - - fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { - nodes.iter().find_map(|n| match n { - MenuNode::Item { id: i, .. } if i == id => Some(n), - MenuNode::Submenu { children, .. } => find(children, id), - _ => None, - }) - } - - /// #2500: a direct-grid binding takes precedence over the borrowed cycle hint. - #[test] - fn grid_menu_shortcut_hints_preserve_direct_bindings_and_clicks() { - let mut a = app(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - let bar = menu_bar(&a); - let nodes = &bar.iter().find(|(title, _)| title == "View").unwrap().1; - assert!(matches!(find(nodes, "view.photoGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "Cmd+Shift+1")); - assert!(matches!(find(nodes, "view.squareGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "G")); - // A hint never assigns the cycle key to another command. - assert_eq!(crate::shortcuts::shortcut_of(&a.ui.settings.keymap, "view.squareGrid"), None); - run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid); - run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid, "clicking the active row does not cycle"); - run_item(&mut a, "view.squareGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::SquareGrid); - run_item(&mut a, "view.gridToggle", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid); - } - - /// #2500: native hints never install a borrowed modifier/function key as a direct-grid - /// accelerator. Plain G remains in AppKit's shortcut column and is dispatched by egui. - #[test] - fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { - let mut keymap = crate::shortcuts::Keymap::new(); - for id in ["view.photoGrid", "view.squareGrid"] { - let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some("G")); - assert_eq!(label, "Photo Grid"); - assert_eq!(accelerator, Some("G")); - assert!(!native_menu_delivers(accelerator.unwrap())); - for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { - crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); - let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); - assert_eq!(label, format!("Photo Grid ({text})")); - assert_eq!(accelerator, None, "egui keeps {key}"); - } - } - crate::shortcuts::assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - let (label, accelerator) = native_item_presentation(&keymap, "view.photoGrid", &Value::Null, "Photo Grid", Some("Cmd+Shift+1")); - assert_eq!(label, "Photo Grid"); - assert_eq!(accelerator, Some("Cmd+Shift+1"), "the row's own shortcut is a real accelerator"); - } - - /// #2500: the control channel runs the real keyboard path exactly once and menu commands - /// continue to select their named grid after the cycle shortcut is remapped. - #[test] - fn grid_menu_shortcut_hints_preserve_control_keyboard_dispatch() { - let mut h = crate::headless::Headless::new(app(), [960.0, 720.0], 1.0); - h.app.ui.view = ViewMode::PhotoGrid; - for _ in 0..3 { - h.step(); - } - let timeout = std::time::Duration::from_secs(20); - let press = |h: &mut crate::headless::Headless, params| { - let reply = h.request("ui.key", params, timeout); - assert_eq!(reply["ok"], true, "{reply}"); - }; - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - crate::shortcuts::assign(&mut h.app.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - for _ in 0..2 { - let reply = h.request("ui.menu.invoke", json!({"command": "view.squareGrid"}), timeout); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - } - } - - /// #2513 review: moving a direct-grid key to the cycle command leaves the visible model - /// unchanged, but must invalidate the native cache so its old accelerator is removed. - #[test] - fn grid_menu_shortcut_hints_invalidate_native_ownership_cache() { - let mut a = app(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+G")).unwrap(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.squareGrid", Some("Cmd+Shift+2")).unwrap(); - let before = menu_bar(&a); - let direct = native_menu_structure(&before, &a.ui.settings.keymap); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - let after = menu_bar(&a); - assert_eq!(before, after, "visible hint text does not expose the ownership change"); - assert_ne!(direct, native_menu_structure(&after, &a.ui.settings.keymap), "rebuild removes the stale direct-grid accelerator"); - } - - /// #2500: grid view rows show the separate cycle command's current shortcut. - #[test] - fn grid_menu_shortcut_hints_follow_the_cycle_key() { - let mut a = app(); - let hint = |a: &LightcraftApp, id: &str| { - let bar = menu_bar(a); - let nodes = &bar.iter().find(|(title, _)| title == "View").expect("View menu").1; - match find(nodes, id).expect("grid row") { - MenuNode::Item { shortcut, .. } => shortcut.clone(), - _ => panic!("grid row must be an item"), - } - }; - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); - } - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); - } - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id), None, "{id}"); - } - } - - #[test] - fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { - let mut a = app(); - let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; - let items = view(&a); - assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); - assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); - a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); - assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); - } - - /// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), - /// only while sorting at random, and the direction items are off for a shuffle. - #[test] - fn sort_menu_lists_reshuffle_once_and_only_enables_it_for_random() { - fn sort_children(bar: &[(String, Vec)]) -> Vec { - let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; - view.iter() - .find_map(|n| match n { - MenuNode::Submenu { label, children } if label == "Sort" => Some(children.clone()), - _ => None, - }) - .expect("Sort submenu") - } - let count = |nodes: &[MenuNode]| nodes.iter().filter(|n| matches!(n, MenuNode::Item { id, .. } if id == "library.shuffle")).count(); - let mut a = app(); - let kids = sort_children(&menu_bar(&a)); - assert_eq!(count(&kids), 1); - assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: false, .. })), "off until Random is chosen"); - a.session.execute("library.sort", &json!({"key": "random"})).expect("sort at random"); - let kids = sort_children(&menu_bar(&a)); - assert_eq!(count(&kids), 1); - assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); - // no direction is shown as chosen while shuffling - let dir_checked = |kids: &[MenuNode]| { - kids.iter() - .filter_map(|n| match n { - MenuNode::Item { label, checked, .. } if label == "Ascending" || label == "Descending" => Some(*checked), - _ => None, - }) - .collect::>() - }; - assert_eq!(dir_checked(&kids), vec![None, None]); - a.session.execute("library.sort", &json!({"key": "fileName"})).expect("sort by name"); - assert!(dir_checked(&sort_children(&menu_bar(&a))).iter().all(Option::is_some), "checks come back for other keys"); - } - - /// File opens with the import entry points, worded as importing (not as adding a sidebar - /// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. - #[test] - fn file_menu_starts_with_the_import_entry_points() { - let bar = menu_bar(&app()); - let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1; - let labels: Vec = file - .iter() - .take(3) - .map(|n| match n { - MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()), - MenuNode::Submenu { label, .. } => format!("{label} ▸"), - MenuNode::Separator => "---".into(), - }) - .collect(); - assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]"); - assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let text = serde_json::to_string(&all).unwrap(); - assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); - } - - /// Back Up / Restore Library are the browser build's (its library lives in browser storage): - /// absent from the desktop's menus, present and wired to the host where it provides them. - #[test] - fn library_backup_items_follow_the_host() { - let all = |app: &LightcraftApp| -> Vec { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() }; - let mut desktop = app(); - assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none()); - assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host"); - let calls = std::rc::Rc::new(std::cell::Cell::new(0)); - let c = calls.clone(); - let services = crate::Services { - backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| { - c.set(c.get() + 1); - Ok(json!({"photos": s.catalog.len()})) - })), - restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))), - ..Default::default() - }; - let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let bar = all(&web); - for id in ["file.backupLibrary", "file.restoreLibrary"] { - assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}"); - } - let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap(); - assert!(r["photos"].as_u64().unwrap() > 0); - assert_eq!(calls.get(), 1); - } - - #[test] - fn bar_follows_the_registry_and_state() { - let mut app = app(); - let bar = menu_bar(&app); - let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); - assert_eq!(titles, MENUS); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - // every engine command with a menu path is reachable (parameterized ones via submenus; items - // that follow the state, like Restore for deleted photos, when it applies) - for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { - assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); - } - for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { - assert!(find(&all, id).is_some(), "{id}"); - } - // no doubled or dangling separators - fn check(v: &[MenuNode]) { - assert_ne!(v.first(), Some(&MenuNode::Separator)); - assert_ne!(v.last(), Some(&MenuNode::Separator)); - for w in v.windows(2) { - assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); - } - for n in v { - if let MenuNode::Submenu { children, .. } = n { - check(children); - } - } - } - bar.iter().for_each(|(_, v)| check(v)); - // live state: undo label, enabled, checked - let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; - assert!(!enabled); - run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); - let bar = menu_bar(&app); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; - assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); - let rated: Vec<_> = all - .iter() - .filter_map(|n| match n { - MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), - _ => None, - }) - .flatten() - .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) - .collect(); - assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); - let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; - assert_eq!(*checked, Some(app.ui.filmstrip)); - } - - #[test] - fn quit_ends_the_file_menu_after_unlisted_entries() { - let app = app(); - // the File menu has registry entries the layout doesn't list (appended as fallbacks) - let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1; - let unlisted: Vec = crate::menus::menu_entries(&app) - .into_iter() - .filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str())) - .map(|e| e.id) - .collect(); - assert!(!unlisted.is_empty(), "no fallback File entries to order"); - let bar = menu_bar(&app); - let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; - // Quit is the last item, alone in its separator group - assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}"); - assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator)); - // every fallback entry is still reachable, before Quit - for id in &unlisted { - assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit"); - } - } - - #[test] - fn export_with_preset_submenu() { - let mut app = app(); - let bar = menu_bar(&app); - let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; - let Some(MenuNode::Submenu { children, .. }) = - file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Export with Preset")) - else { - panic!("no Export with Preset submenu") - }; - let presets: Vec<_> = children - .iter() - .filter_map(|n| match n { - MenuNode::Item { id, params, .. } if id == "app.export" => params["preset"].as_str().map(str::to_string), - _ => None, - }) - .collect(); - assert_eq!(presets.first().map(String::as_str), Some("JPEG (Small)")); - assert!(find(children, "dialog.export").is_some(), "Custom… opens the dialog"); - // a user preset appears after the built-ins; choosing it exports with its settings - app.session.execute("export.savePreset", &json!({"name": "Tiny PNG", "params": {"format": "png", "width": 40}})).unwrap(); - let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); - let w = written.clone(); - app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { - w.lock().unwrap().push((p.to_string(), b.to_vec())); - Ok(()) - })); - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": "/nonexistent-lc-test"})).unwrap(); - assert_eq!(r["files"][0]["width"], 40, "{r}"); - let w = written.lock().unwrap(); - assert!(w[0].0.starts_with("/nonexistent-lc-test/") && w[0].0.ends_with(".png"), "{}", w[0].0); - assert!(w[0].1.starts_with(b"\x89PNG")); - // remembered (expanded) for Export with Previous, folder included - let last = app.session.last_export.clone().unwrap(); - assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); - // a blank folder (the Export dialog's Folder field cleared) is refused with a clear message - // instead of writing into the working directory - let n = w.len(); - drop(w); - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); - assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); - assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); - // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it - // gets its own error rather than a file written elsewhere (issue #956) - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); - let e = r.unwrap_err(); - assert!(e.contains("no folder on disk"), "{e}"); - assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); - } - - #[test] - fn select_by_submenu_and_text_prompt() { - let mut app = app(); - let bar = menu_bar(&app); - let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; - let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) - else { - panic!("no Select by submenu") - }; - let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; - let r = run_item(&mut app, id, params.clone()).unwrap(); - assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}"); - // the one-field prompt runs its command with the typed value - app.session.execute("version.create", &json!({"name": "A"})).unwrap(); - crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name"); - if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { - *value = " Final ".into(); - } - let d = app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - let id = app.session.active().unwrap(); - assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final"); - } - - #[test] - fn help_opens_the_docs_and_merge_last_needs_photos() { - let mut app = app(); - let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let o = opened.clone(); - app.services.open_url = Some(Box::new(move |u: &str| { - o.lock().unwrap().push(u.to_string()); - Ok(()) - })); - run_item(&mut app, "app.help", Value::Null).unwrap(); - assert_eq!(opened.lock().unwrap().as_slice(), [crate::links::HELP]); - // merging with the last settings needs a selection of two or more - let first = app.session.visible()[0].0; - app.session.execute("library.select", &json!({"ids": [first]})).unwrap(); - assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err()); - } - - /// #260: Help ▸ Open Log Folder reveals the log file the host names. Without one (the web, - /// `--memory`) or without a file manager to show it, the item is off and the command says why. - #[test] - fn open_log_folder_reveals_the_hosts_log_file() { - let mut app = app(); - let help = |app: &LightcraftApp| menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); - assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: false, .. })), "listed in Help, off without a log"); - assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); - let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let s = shown.clone(); - app.services.reveal = Some(Box::new(move |p: &str| { - s.lock().unwrap().push(p.to_string()); - Ok(()) - })); - // a file manager alone is not enough: this session keeps no log - assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); - assert!(shown.lock().unwrap().is_empty()); - let log = "/home/a/.config/lightcraft/logs/lightcraft.log"; - app.services.log_file = Some(log.into()); - assert!(crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: true, .. }))); - let r = run_item(&mut app, "app.openLogFolder", Value::Null).unwrap(); - assert_eq!(r["path"], log); - assert_eq!(shown.lock().unwrap().as_slice(), [log]); - // the file manager's failure is the command's - app.services.reveal = Some(Box::new(|_: &str| Err("no file manager".into()))); - assert_eq!(run_item(&mut app, "app.openLogFolder", Value::Null).unwrap_err(), "no file manager"); - } - - #[test] - fn find_missing_and_locate() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("a")).unwrap(); - std::fs::create_dir_all(dir.join("b")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("a/one.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap(); - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap(); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}"); - // Locate: an explicit file - std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap(); - let id = app.session.catalog.photos().next().unwrap().id; - app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); - run_item(&mut app, "photo.locate", json!({"path": dir.join("one-renamed.png").to_string_lossy()})).unwrap(); - let p = app.session.catalog.photo(id).unwrap(); - assert_eq!(p.file_name, "one-renamed.png"); - let _ = std::fs::remove_dir_all(&dir); - } - - // Regression test for #49: the in-window menu headings (File, Edit, ...) sat only egui's default - // 8 px apart, too close to read or aim at. - #[test] - fn in_window_menu_headings_are_spaced_apart() { - let mut a = app(); - a.native_menu = false; - let mut h = crate::headless::Headless::new(a, [1400.0, 900.0], 1.0); - h.step(); - h.step(); - let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} is on screen")); - let titles: Vec<_> = ["File", "Edit", "View", "Photo", "Window", "Help"].iter().map(|t| rect(&format!("menu:{t}"))).collect(); - for pair in titles.windows(2) { - let gap = pair[1].left() - pair[0].right(); - assert!(gap >= 24.0 - 0.5, "headings {:?} and {:?} are only {gap} px apart", pair[0], pair[1]); - } - } - - /// A backup the host refuses (changes still unsaved, issue #1508) says why when started from - /// the menu, as a refused export does; the refusal used to reach only the control channel. - #[test] - fn a_refused_backup_from_the_menu_shows_why() { - // the host refuses, as the web one does while changes are still unsaved (issue #1508) - let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; - let services = crate::Services { - backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), - ..Default::default() - }; - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); - let t = std::time::Duration::from_secs(30); - h.settle(t); - for id in ["menu:File", "menuitem:file.backupLibrary"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - h.step(); - } - // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) - let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); - assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); - } - - /// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search - /// failures as errors instead of returning `Ok(null)`. - #[test] - fn find_missing_waited_invalid_folder_returns_error_with_zero_mutations() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-err-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let dummy = dir.join("dummy.png"); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(&dummy, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dummy.to_string_lossy()]})).unwrap(); - // remove the file on disk so the photo is missing - std::fs::remove_file(&dummy).unwrap(); - let id = app.session.catalog.photos().next().unwrap().id; - let original_path = match &app.session.catalog.photo(id).unwrap().source { - lightcraft_catalog::Source::File { path } => path.clone(), - _ => panic!("expected file source"), - }; - let undo_count_before = app.session.undo.len(); - - // 1. Point at a nonexistent directory - let bad_dir = dir.join("nonexistent_subdir"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": bad_dir.to_string_lossy(), "wait": true})).unwrap_err(); - assert!(err.contains("not a folder"), "expected 'not a folder' error, got: {err}"); - assert!(app.ui.toast.as_ref().map(|t| t.0.contains("not a folder")).unwrap_or(false), "toast should reflect failure"); - assert!(app.ui.last_find_missing.is_none(), "last_find_missing must remain None on failure"); - - // 2. Invalid timeout parameters are rejected cleanly - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": -1.0})).unwrap_err(); - assert!(err.contains("non-negative number of seconds"), "{err}"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": "invalid"})).unwrap_err(); - assert!(err.contains("number of seconds"), "{err}"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 1e300})).unwrap_err(); - assert!(err.contains("too large"), "{err}"); - - // Catalog and photo path must not have been mutated - assert_eq!(app.session.undo.len(), undo_count_before, "no undo steps added on failed search"); - let path_after = match &app.session.catalog.photo(id).unwrap().source { - lightcraft_catalog::Source::File { path } => path.clone(), - _ => panic!("expected file source"), - }; - assert_eq!(path_after, original_path, "missing photo path must not change on failure"); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` returns structured results on success, - /// and background mode (`wait: false` or omitted) continues returning `{"background": true}`. - #[test] - fn find_missing_waited_structured_results_and_background_mode() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-bg-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("orig")).unwrap(); - std::fs::create_dir_all(dir.join("found")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); - - // Background mode (wait omitted): returns {"background": true} immediately - let bg = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); - assert_eq!(bg, json!({"background": true})); - - // Wait for background task to complete - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); - let last = app.ui.last_find_missing.as_ref().unwrap(); - assert_eq!(last["found"].as_array().map(Vec::len), Some(1)); - - // Now move again and run with wait: true -> returns structured results directly - std::fs::create_dir_all(dir.join("found2")).unwrap(); - std::fs::rename(dir.join("found/pic.png"), dir.join("found2/pic.png")).unwrap(); - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found2").to_string_lossy(), "wait": true})).unwrap(); - assert!(r.get("found").is_some(), "must have 'found' array: {r}"); - assert!(r.get("missing").is_some(), "must have 'missing' count: {r}"); - assert!(r.get("ambiguous").is_some(), "must have 'ambiguous' array: {r}"); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1)); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` when timed out returns an actionable error - /// indicating timeout and that the search continues in the background, rather than returning `Ok(null)`. - #[test] - fn find_missing_waited_timeout_behavior() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-to-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - // Injected test-only worker delay so wait times out deterministically - app.ui.find_missing_test_delay = Some(std::time::Duration::from_millis(100)); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 0.005})).unwrap_err(); - - assert!(err.contains("search timed out"), "error should mention timeout: {err}"); - assert!(err.contains("search continues in the background"), "error should document continuation: {err}"); - - // The background task continues running in app.tasks - assert!(app.tasks.is_running("Find Missing Photos"), "task should continue running in background"); - - // Clean up the running task so no worker hangs - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait_for(&mut app, &ctx, "Find Missing Photos", std::time::Duration::from_secs(5))); - assert!(!app.tasks.is_running("Find Missing Photos")); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` must return when its own search finishes, - /// even if unrelated background tasks remain active in `app.tasks`. - #[test] - fn find_missing_waited_completes_while_unrelated_task_remains_active() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-multi-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("orig")).unwrap(); - std::fs::create_dir_all(dir.join("found")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); - - // Spawn an unrelated background task that blocks on a channel until released - let (tx_unblock, rx_unblock) = std::sync::mpsc::channel::<()>(); - crate::tasks::spawn( - &mut app, - "Unrelated Long Background Task", - None, - move || { - let _ = rx_unblock.recv(); - }, - |_app, _ctx, _| {}, - ) - .unwrap(); - assert!(app.tasks.is_running("Unrelated Long Background Task")); - - // Run file.findMissing with wait: true: must complete without waiting for the unrelated task - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy(), "wait": true, "timeout": 5.0})).unwrap(); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "Find Missing must complete successfully: {r}"); - - // The unrelated task must STILL be active - assert!(app.tasks.is_running("Unrelated Long Background Task"), "unrelated task must still be running"); - - // Clean up the blocked background task - let _ = tx_unblock.send(()); - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); - assert!(app.tasks.is_empty()); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - - fn auto_tag_from_tracklog() { - let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let gpx = dir.join("walk.gpx"); - std::fs::write( - &gpx, - r#" - - - "#, - ) - .unwrap(); - let mut app = app(); - let id = app.session.visible()[0].0; - app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); - app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap(); - app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap(); - // in the Photo menu; without a file dialog (headless) it is disabled - let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1; - let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") }; - assert!(!enabled); - // a file without an offset asks for the camera's time zone; confirming runs the tagging - run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap(); - let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") }; - *value = " +02:00 ".into(); - let d = app.ui.dialog.take().unwrap(); - let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - assert_eq!(r["tagged"], 1, "{r}"); - let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap(); - assert_eq!(p.meta.gps, Some((46.001, 7.002))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn metadata_preset_menu() { - let mut app = app(); - app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap(); - run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap(); - if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { - *value = "Mine".into(); - } - let d = app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - let bar = menu_bar(&app); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") }; - assert_eq!(params["name"], "Mine"); - let other = app.session.visible()[1]; - app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); - run_item(&mut app, id, params.clone()).unwrap(); - assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me"); - } - - #[test] - fn shortcut_text_per_platform() { - assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); - assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); - assert_eq!(shortcut_text("Delete", true), "⌫"); - } - - /// Every item of a menu, submenus included. - fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { - nodes - .iter() - .flat_map(|n| match n { - MenuNode::Submenu { children, .. } => flat(children), - MenuNode::Item { .. } => vec![n], - MenuNode::Separator => vec![], - }) - .collect() - } - - /// Given the menu bar, Then an item that turns something on or off is named for the thing, - /// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". - /// Show Original is a view (\), not a switch: it keeps Lightroom's name. - #[test] - fn toggles_are_named_for_the_thing_not_show() { - let mut a = app(); - a.ui.view = ViewMode::Detail; - let shows: Vec = menu_bar(&a) - .iter() - .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::>()) - .filter_map(|n| match n { - MenuNode::Item { id, params, label, checked: Some(_), .. } - if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => - { - Some(format!("{id}: {label}")) - } - _ => None, - }) - .collect(); - assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); - } - - /// Given every command with a menu path, Then no two of them share a label, and none is named - /// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart - /// by name ("Detail" the view and "Detail" the Edit section were two things called the same). - #[test] - fn no_two_menu_commands_share_a_label() { - let entries = crate::menus::menu_entries(&app()); - let mut clashes = Vec::new(); - for (i, e) in entries.iter().enumerate() { - if MENUS.contains(&e.label.as_str()) { - clashes.push(format!("{} is called like the {} menu", e.id, e.label)); - } - if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { - clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); - } - } - assert!(clashes.is_empty(), "{clashes:#?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/menus/menubar/tests.rs b/crates/ui-egui/src/menus/menubar/tests.rs new file mode 100644 index 000000000..1e2fe388d --- /dev/null +++ b/crates/ui-egui/src/menus/menubar/tests.rs @@ -0,0 +1,825 @@ +use super::*; + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) +} + +fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { + nodes.iter().find_map(|n| match n { + MenuNode::Item { id: i, .. } if i == id => Some(n), + MenuNode::Submenu { children, .. } => find(children, id), + _ => None, + }) +} + +/// #2500: a direct-grid binding takes precedence over the borrowed cycle hint. +#[test] +fn grid_menu_shortcut_hints_preserve_direct_bindings_and_clicks() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let bar = menu_bar(&a); + let nodes = &bar.iter().find(|(title, _)| title == "View").unwrap().1; + assert!(matches!(find(nodes, "view.photoGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "Cmd+Shift+1")); + assert!(matches!(find(nodes, "view.squareGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "G")); + // A hint never assigns the cycle key to another command. + assert_eq!(crate::shortcuts::shortcut_of(&a.ui.settings.keymap, "view.squareGrid"), None); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid, "clicking the active row does not cycle"); + run_item(&mut a, "view.squareGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + run_item(&mut a, "view.gridToggle", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); +} + +/// #2500: native hints never install a borrowed modifier/function key as a direct-grid +/// accelerator. Plain G remains in AppKit's shortcut column and is dispatched by egui. +#[test] +fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { + let mut keymap = crate::shortcuts::Keymap::new(); + for id in ["view.photoGrid", "view.squareGrid"] { + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some("G")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("G")); + assert!(!native_menu_delivers(accelerator.unwrap())); + for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { + crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); + assert_eq!(label, format!("Photo Grid ({text})")); + assert_eq!(accelerator, None, "egui keeps {key}"); + } + } + crate::shortcuts::assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, "view.photoGrid", &Value::Null, "Photo Grid", Some("Cmd+Shift+1")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("Cmd+Shift+1"), "the row's own shortcut is a real accelerator"); +} + +/// #2500: the control channel runs the real keyboard path exactly once and menu commands +/// continue to select their named grid after the cycle shortcut is remapped. +#[test] +fn grid_menu_shortcut_hints_preserve_control_keyboard_dispatch() { + let mut h = crate::headless::Headless::new(app(), [960.0, 720.0], 1.0); + h.app.ui.view = ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + let timeout = std::time::Duration::from_secs(20); + let press = |h: &mut crate::headless::Headless, params| { + let reply = h.request("ui.key", params, timeout); + assert_eq!(reply["ok"], true, "{reply}"); + }; + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + crate::shortcuts::assign(&mut h.app.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + for _ in 0..2 { + let reply = h.request("ui.menu.invoke", json!({"command": "view.squareGrid"}), timeout); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + } +} + +/// #2513 review: moving a direct-grid key to the cycle command leaves the visible model +/// unchanged, but must invalidate the native cache so its old accelerator is removed. +#[test] +fn grid_menu_shortcut_hints_invalidate_native_ownership_cache() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+G")).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.squareGrid", Some("Cmd+Shift+2")).unwrap(); + let before = menu_bar(&a); + let direct = native_menu_structure(&before, &a.ui.settings.keymap); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + let after = menu_bar(&a); + assert_eq!(before, after, "visible hint text does not expose the ownership change"); + assert_ne!(direct, native_menu_structure(&after, &a.ui.settings.keymap), "rebuild removes the stale direct-grid accelerator"); +} + +/// #2500: grid view rows show the separate cycle command's current shortcut. +#[test] +fn grid_menu_shortcut_hints_follow_the_cycle_key() { + let mut a = app(); + let hint = |a: &LightcraftApp, id: &str| { + let bar = menu_bar(a); + let nodes = &bar.iter().find(|(title, _)| title == "View").expect("View menu").1; + match find(nodes, id).expect("grid row") { + MenuNode::Item { shortcut, .. } => shortcut.clone(), + _ => panic!("grid row must be an item"), + } + }; + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id), None, "{id}"); + } +} + +#[test] +fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { + let mut a = app(); + let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; + let items = view(&a); + assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); + assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); + a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); + assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); +} + +/// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), +/// only while sorting at random, and the direction items are off for a shuffle. +#[test] +fn sort_menu_lists_reshuffle_once_and_only_enables_it_for_random() { + fn sort_children(bar: &[(String, Vec)]) -> Vec { + let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; + view.iter() + .find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Sort" => Some(children.clone()), + _ => None, + }) + .expect("Sort submenu") + } + let count = |nodes: &[MenuNode]| nodes.iter().filter(|n| matches!(n, MenuNode::Item { id, .. } if id == "library.shuffle")).count(); + let mut a = app(); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: false, .. })), "off until Random is chosen"); + a.session.execute("library.sort", &json!({"key": "random"})).expect("sort at random"); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); + // no direction is shown as chosen while shuffling + let dir_checked = |kids: &[MenuNode]| { + kids.iter() + .filter_map(|n| match n { + MenuNode::Item { label, checked, .. } if label == "Ascending" || label == "Descending" => Some(*checked), + _ => None, + }) + .collect::>() + }; + assert_eq!(dir_checked(&kids), vec![None, None]); + a.session.execute("library.sort", &json!({"key": "fileName"})).expect("sort by name"); + assert!(dir_checked(&sort_children(&menu_bar(&a))).iter().all(Option::is_some), "checks come back for other keys"); +} + +/// File opens with the import entry points, worded as importing (not as adding a sidebar +/// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. +#[test] +fn file_menu_starts_with_the_import_entry_points() { + let bar = menu_bar(&app()); + let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1; + let labels: Vec = file + .iter() + .take(3) + .map(|n| match n { + MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()), + MenuNode::Submenu { label, .. } => format!("{label} ▸"), + MenuNode::Separator => "---".into(), + }) + .collect(); + assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]"); + assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let text = serde_json::to_string(&all).unwrap(); + assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); +} + +/// Back Up / Restore Library are the browser build's (its library lives in browser storage): +/// absent from the desktop's menus, present and wired to the host where it provides them. +#[test] +fn library_backup_items_follow_the_host() { + let all = |app: &LightcraftApp| -> Vec { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() }; + let mut desktop = app(); + assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none()); + assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host"); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let c = calls.clone(); + let services = crate::Services { + backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| { + c.set(c.get() + 1); + Ok(json!({"photos": s.catalog.len()})) + })), + restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))), + ..Default::default() + }; + let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let bar = all(&web); + for id in ["file.backupLibrary", "file.restoreLibrary"] { + assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}"); + } + let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap(); + assert!(r["photos"].as_u64().unwrap() > 0); + assert_eq!(calls.get(), 1); +} + +#[test] +fn bar_follows_the_registry_and_state() { + let mut app = app(); + let bar = menu_bar(&app); + let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(titles, MENUS); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + // every engine command with a menu path is reachable (parameterized ones via submenus; items + // that follow the state, like Restore for deleted photos, when it applies) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { + assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); + } + for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { + assert!(find(&all, id).is_some(), "{id}"); + } + // no doubled or dangling separators + fn check(v: &[MenuNode]) { + assert_ne!(v.first(), Some(&MenuNode::Separator)); + assert_ne!(v.last(), Some(&MenuNode::Separator)); + for w in v.windows(2) { + assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); + } + for n in v { + if let MenuNode::Submenu { children, .. } = n { + check(children); + } + } + } + bar.iter().for_each(|(_, v)| check(v)); + // live state: undo label, enabled, checked + let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert!(!enabled); + run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); + let rated: Vec<_> = all + .iter() + .filter_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), + _ => None, + }) + .flatten() + .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) + .collect(); + assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); + let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; + assert_eq!(*checked, Some(app.ui.filmstrip)); +} + +#[test] +fn quit_ends_the_file_menu_after_unlisted_entries() { + let app = app(); + // the File menu has registry entries the layout doesn't list (appended as fallbacks) + let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1; + let unlisted: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str())) + .map(|e| e.id) + .collect(); + assert!(!unlisted.is_empty(), "no fallback File entries to order"); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + // Quit is the last item, alone in its separator group + assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}"); + assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator)); + // every fallback entry is still reachable, before Quit + for id in &unlisted { + assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit"); + } +} + +#[test] +fn export_with_preset_submenu() { + let mut app = app(); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = + file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Export with Preset")) + else { + panic!("no Export with Preset submenu") + }; + let presets: Vec<_> = children + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, params, .. } if id == "app.export" => params["preset"].as_str().map(str::to_string), + _ => None, + }) + .collect(); + assert_eq!(presets.first().map(String::as_str), Some("JPEG (Small)")); + assert!(find(children, "dialog.export").is_some(), "Custom… opens the dialog"); + // a user preset appears after the built-ins; choosing it exports with its settings + app.session.execute("export.savePreset", &json!({"name": "Tiny PNG", "params": {"format": "png", "width": 40}})).unwrap(); + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); + let w = written.clone(); + app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { + w.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": "/nonexistent-lc-test"})).unwrap(); + assert_eq!(r["files"][0]["width"], 40, "{r}"); + let w = written.lock().unwrap(); + assert!(w[0].0.starts_with("/nonexistent-lc-test/") && w[0].0.ends_with(".png"), "{}", w[0].0); + assert!(w[0].1.starts_with(b"\x89PNG")); + // remembered (expanded) for Export with Previous, folder included + let last = app.session.last_export.clone().unwrap(); + assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); + // a blank folder (the Export dialog's Folder field cleared) is refused with a clear message + // instead of writing into the working directory + let n = w.len(); + drop(w); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); + assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); + assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); + // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it + // gets its own error rather than a file written elsewhere (issue #956) + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); + let e = r.unwrap_err(); + assert!(e.contains("no folder on disk"), "{e}"); + assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); +} + +#[test] +fn select_by_submenu_and_text_prompt() { + let mut app = app(); + let bar = menu_bar(&app); + let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) else { + panic!("no Select by submenu") + }; + let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; + let r = run_item(&mut app, id, params.clone()).unwrap(); + assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}"); + // the one-field prompt runs its command with the typed value + app.session.execute("version.create", &json!({"name": "A"})).unwrap(); + crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name"); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = " Final ".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let id = app.session.active().unwrap(); + assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final"); +} + +#[test] +fn help_opens_the_docs_and_merge_last_needs_photos() { + let mut app = app(); + let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let o = opened.clone(); + app.services.open_url = Some(Box::new(move |u: &str| { + o.lock().unwrap().push(u.to_string()); + Ok(()) + })); + run_item(&mut app, "app.help", Value::Null).unwrap(); + assert_eq!(opened.lock().unwrap().as_slice(), [crate::links::HELP]); + // merging with the last settings needs a selection of two or more + let first = app.session.visible()[0].0; + app.session.execute("library.select", &json!({"ids": [first]})).unwrap(); + assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err()); +} + +/// #260: Help ▸ Open Log Folder reveals the log file the host names. Without one (the web, +/// `--memory`) or without a file manager to show it, the item is off and the command says why. +#[test] +fn open_log_folder_reveals_the_hosts_log_file() { + let mut app = app(); + let help = |app: &LightcraftApp| menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); + assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: false, .. })), "listed in Help, off without a log"); + assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); + let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let s = shown.clone(); + app.services.reveal = Some(Box::new(move |p: &str| { + s.lock().unwrap().push(p.to_string()); + Ok(()) + })); + // a file manager alone is not enough: this session keeps no log + assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); + assert!(shown.lock().unwrap().is_empty()); + let log = "/home/a/.config/lightcraft/logs/lightcraft.log"; + app.services.log_file = Some(log.into()); + assert!(crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: true, .. }))); + let r = run_item(&mut app, "app.openLogFolder", Value::Null).unwrap(); + assert_eq!(r["path"], log); + assert_eq!(shown.lock().unwrap().as_slice(), [log]); + // the file manager's failure is the command's + app.services.reveal = Some(Box::new(|_: &str| Err("no file manager".into()))); + assert_eq!(run_item(&mut app, "app.openLogFolder", Value::Null).unwrap_err(), "no file manager"); +} + +#[test] +fn find_missing_and_locate() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("a")).unwrap(); + std::fs::create_dir_all(dir.join("b")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a/one.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap(); + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap(); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}"); + // Locate: an explicit file + std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap(); + let id = app.session.catalog.photos().next().unwrap().id; + app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + run_item(&mut app, "photo.locate", json!({"path": dir.join("one-renamed.png").to_string_lossy()})).unwrap(); + let p = app.session.catalog.photo(id).unwrap(); + assert_eq!(p.file_name, "one-renamed.png"); + let _ = std::fs::remove_dir_all(&dir); +} + +// Regression test for #49: the in-window menu headings (File, Edit, ...) sat only egui's default +// 8 px apart, too close to read or aim at. +#[test] +fn in_window_menu_headings_are_spaced_apart() { + let mut a = app(); + a.native_menu = false; + let mut h = crate::headless::Headless::new(a, [1400.0, 900.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} is on screen")); + let titles: Vec<_> = ["File", "Edit", "View", "Photo", "Window", "Help"].iter().map(|t| rect(&format!("menu:{t}"))).collect(); + for pair in titles.windows(2) { + let gap = pair[1].left() - pair[0].right(); + assert!(gap >= 24.0 - 0.5, "headings {:?} and {:?} are only {gap} px apart", pair[0], pair[1]); + } +} + +/// A backup the host refuses (changes still unsaved, issue #1508) says why when started from +/// the menu, as a refused export does; the refusal used to reach only the control channel. +#[test] +fn a_refused_backup_from_the_menu_shows_why() { + // the host refuses, as the web one does while changes are still unsaved (issue #1508) + let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; + let services = + crate::Services { backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); + let t = std::time::Duration::from_secs(30); + h.settle(t); + for id in ["menu:File", "menuitem:file.backupLibrary"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + } + // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); +} + +/// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search +/// failures as errors instead of returning `Ok(null)`. +#[test] +fn find_missing_waited_invalid_folder_returns_error_with_zero_mutations() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-err-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dummy = dir.join("dummy.png"); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(&dummy, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dummy.to_string_lossy()]})).unwrap(); + // remove the file on disk so the photo is missing + std::fs::remove_file(&dummy).unwrap(); + let id = app.session.catalog.photos().next().unwrap().id; + let original_path = match &app.session.catalog.photo(id).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!("expected file source"), + }; + let undo_count_before = app.session.undo.len(); + + // 1. Point at a nonexistent directory + let bad_dir = dir.join("nonexistent_subdir"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": bad_dir.to_string_lossy(), "wait": true})).unwrap_err(); + assert!(err.contains("not a folder"), "expected 'not a folder' error, got: {err}"); + assert!(app.ui.toast.as_ref().map(|t| t.0.contains("not a folder")).unwrap_or(false), "toast should reflect failure"); + assert!(app.ui.last_find_missing.is_none(), "last_find_missing must remain None on failure"); + + // 2. Invalid timeout parameters are rejected cleanly + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": -1.0})).unwrap_err(); + assert!(err.contains("non-negative number of seconds"), "{err}"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": "invalid"})).unwrap_err(); + assert!(err.contains("number of seconds"), "{err}"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 1e300})).unwrap_err(); + assert!(err.contains("too large"), "{err}"); + + // Catalog and photo path must not have been mutated + assert_eq!(app.session.undo.len(), undo_count_before, "no undo steps added on failed search"); + let path_after = match &app.session.catalog.photo(id).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!("expected file source"), + }; + assert_eq!(path_after, original_path, "missing photo path must not change on failure"); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` returns structured results on success, +/// and background mode (`wait: false` or omitted) continues returning `{"background": true}`. +#[test] +fn find_missing_waited_structured_results_and_background_mode() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-bg-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("orig")).unwrap(); + std::fs::create_dir_all(dir.join("found")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); + + // Background mode (wait omitted): returns {"background": true} immediately + let bg = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); + assert_eq!(bg, json!({"background": true})); + + // Wait for background task to complete + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); + let last = app.ui.last_find_missing.as_ref().unwrap(); + assert_eq!(last["found"].as_array().map(Vec::len), Some(1)); + + // Now move again and run with wait: true -> returns structured results directly + std::fs::create_dir_all(dir.join("found2")).unwrap(); + std::fs::rename(dir.join("found/pic.png"), dir.join("found2/pic.png")).unwrap(); + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found2").to_string_lossy(), "wait": true})).unwrap(); + assert!(r.get("found").is_some(), "must have 'found' array: {r}"); + assert!(r.get("missing").is_some(), "must have 'missing' count: {r}"); + assert!(r.get("ambiguous").is_some(), "must have 'ambiguous' array: {r}"); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1)); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` when timed out returns an actionable error +/// indicating timeout and that the search continues in the background, rather than returning `Ok(null)`. +#[test] +fn find_missing_waited_timeout_behavior() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-to-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + // Injected test-only worker delay so wait times out deterministically + app.ui.find_missing_test_delay = Some(std::time::Duration::from_millis(100)); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 0.005})).unwrap_err(); + + assert!(err.contains("search timed out"), "error should mention timeout: {err}"); + assert!(err.contains("search continues in the background"), "error should document continuation: {err}"); + + // The background task continues running in app.tasks + assert!(app.tasks.is_running("Find Missing Photos"), "task should continue running in background"); + + // Clean up the running task so no worker hangs + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait_for(&mut app, &ctx, "Find Missing Photos", std::time::Duration::from_secs(5))); + assert!(!app.tasks.is_running("Find Missing Photos")); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` must return when its own search finishes, +/// even if unrelated background tasks remain active in `app.tasks`. +#[test] +fn find_missing_waited_completes_while_unrelated_task_remains_active() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-multi-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("orig")).unwrap(); + std::fs::create_dir_all(dir.join("found")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); + + // Spawn an unrelated background task that blocks on a channel until released + let (tx_unblock, rx_unblock) = std::sync::mpsc::channel::<()>(); + crate::tasks::spawn( + &mut app, + "Unrelated Long Background Task", + None, + move || { + let _ = rx_unblock.recv(); + }, + |_app, _ctx, _| {}, + ) + .unwrap(); + assert!(app.tasks.is_running("Unrelated Long Background Task")); + + // Run file.findMissing with wait: true: must complete without waiting for the unrelated task + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy(), "wait": true, "timeout": 5.0})).unwrap(); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "Find Missing must complete successfully: {r}"); + + // The unrelated task must STILL be active + assert!(app.tasks.is_running("Unrelated Long Background Task"), "unrelated task must still be running"); + + // Clean up the blocked background task + let _ = tx_unblock.send(()); + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); + assert!(app.tasks.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] + +fn auto_tag_from_tracklog() { + let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let gpx = dir.join("walk.gpx"); + std::fs::write( + &gpx, + r#" + + + "#, + ) + .unwrap(); + let mut app = app(); + let id = app.session.visible()[0].0; + app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); + app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap(); + app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap(); + // in the Photo menu; without a file dialog (headless) it is disabled + let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1; + let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") }; + assert!(!enabled); + // a file without an offset asks for the camera's time zone; confirming runs the tagging + run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap(); + let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") }; + *value = " +02:00 ".into(); + let d = app.ui.dialog.take().unwrap(); + let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + assert_eq!(r["tagged"], 1, "{r}"); + let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap(); + assert_eq!(p.meta.gps, Some((46.001, 7.002))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn metadata_preset_menu() { + let mut app = app(); + app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap(); + run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap(); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = "Mine".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") }; + assert_eq!(params["name"], "Mine"); + let other = app.session.visible()[1]; + app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); + run_item(&mut app, id, params.clone()).unwrap(); + assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me"); +} + +/// Native menu accelerators never pass through `shortcuts::handle`: a command queued by the +/// native menu must obey the same blocking-window/prompt policy. Settings… and About must not +/// open behind the blocking window, and Quit stays available for its existing confirmation. +#[test] +fn native_menu_commands_respect_the_blocking_window_policy() { + use crate::panels::library_problem::LibraryProblem; + use crate::state::Dialog; + + let mut h = crate::headless::Headless::new(app(), [1000.0, 760.0], 1.0); + h.settle(std::time::Duration::from_secs(20)); + h.app.ui.view = ViewMode::PhotoGrid; + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "view.gridToggle"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "app.quit"), NativeCommand::Quit, "Quit keeps its own confirmation"); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "Settings must not open behind the blocking library window"); + assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "About must not open behind the blocking library window"); + assert_eq!(run_native_command(&mut h.app, "view.gridToggle", Value::Null), NativeCommand::Blocked); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a menu command did not act on the library behind the window"); + assert_eq!( + run_native_command(&mut h.app, "app.quit", Value::Null), + NativeCommand::Quit, + "Quit stays for the host's existing tasks/unsaved-changes confirmation" + ); + assert!(h.app.ui.dialog.is_none()); + + // The launch list owns input the same way. + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + h.app.library_problem = Some(LibraryProblem::choose(&settings)); + assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); + assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "About must not open behind the launch list"); + // The launch list's own New Library prompt is not a background barrier: it keeps its + // priority instead of being treated as an unrelated dialog behind the list. + h.app.ui.dialog = Some(Dialog::TextPrompt { + title: "New Library".into(), + hint: "Library name".into(), + value: "New Library".into(), + command: "app.newLibrary".into(), + params: json!({}), + key: "name".into(), + }); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run, "the launch list's own prompt is not a background barrier"); + h.app.ui.dialog = None; + + // A quit/settings notice owns input in the same way. + h.app.library_problem = None; + h.app.notices.push("The settings file could not be read and was reset.".into()); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "Settings must not open behind a notice"); + + // Once nothing blocks input, the same queued command runs normally. + h.app.notices.clear(); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Run); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Settings { .. })), "the command runs when no barrier owns input"); +} + +#[test] +fn shortcut_text_per_platform() { + assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); + assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); + assert_eq!(shortcut_text("Delete", true), "⌫"); +} + +/// Every item of a menu, submenus included. +fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { + nodes + .iter() + .flat_map(|n| match n { + MenuNode::Submenu { children, .. } => flat(children), + MenuNode::Item { .. } => vec![n], + MenuNode::Separator => vec![], + }) + .collect() +} + +/// Given the menu bar, Then an item that turns something on or off is named for the thing, +/// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". +/// Show Original is a view (\), not a switch: it keeps Lightroom's name. +#[test] +fn toggles_are_named_for_the_thing_not_show() { + let mut a = app(); + a.ui.view = ViewMode::Detail; + let shows: Vec = menu_bar(&a) + .iter() + .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::>()) + .filter_map(|n| match n { + MenuNode::Item { id, params, label, checked: Some(_), .. } + if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => + { + Some(format!("{id}: {label}")) + } + _ => None, + }) + .collect(); + assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); +} + +/// Given every command with a menu path, Then no two of them share a label, and none is named +/// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart +/// by name ("Detail" the view and "Detail" the Edit section were two things called the same). +#[test] +fn no_two_menu_commands_share_a_label() { + let entries = crate::menus::menu_entries(&app()); + let mut clashes = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if MENUS.contains(&e.label.as_str()) { + clashes.push(format!("{} is called like the {} menu", e.id, e.label)); + } + if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { + clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); + } + } + assert!(clashes.is_empty(), "{clashes:#?}"); +} diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs new file mode 100644 index 000000000..c550ff0d4 --- /dev/null +++ b/crates/ui-egui/src/menus/run.rs @@ -0,0 +1,1992 @@ +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::*; + +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) { + app.ui.right = RightPanel::None; + app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); + } else { + app.ui.right = p; + app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name))); + if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + } + if p != RightPanel::Masking && app.ui.tool != "wbPicker" { + app.ui.tool.clear(); + } + let _ = app.session.end_interaction(); +} + +/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's +/// (and its selected spot's) or the Masking brush's. +fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { + if app.ui.right == RightPanel::Remove { + app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); + app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); + if app.session.active_spot.is_some() { + let mut p = json!({}); + if k != 1.0 { + p["size"] = json!(app.ui.remove_size); + } + if df != 0.0 { + p["feather"] = json!(app.ui.remove_feather); + } + let _ = app.run("spot.update", p); + } + json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) + } else { + app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); + app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) + } +} + +/// The `parent` folder id of a `dialog.new*` command (none: the top level). +fn parent_param(p: &Value) -> Option { + p.get("parent").and_then(Value::as_u64) +} + +/// `file.addPhotos` / `file.addFolder` / `file.addFromDevice` `{album?}`: the review of the scan +/// just started opens with that album chosen (an album's context menu ▸ Import ▸ …). An error +/// means another scan is running. +fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result) -> Result { + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.album = p.get("album").and_then(Value::as_u64); + } + r +} + +/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). +pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { + if let Some(s) = v.as_str() { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); + return Some([c(0)?, c(2)?, c(4)?]); + } + let a = v.as_array()?; + let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); + Some([c(0)?, c(1)?, c(2)?]) +} + +/// The left-sidebar section a `view.sidebar*` command names (`section`), which this build must have. +fn sidebar_section_param(command: &str, p: &Value) -> Result { + use crate::sidebar::SidebarSection; + let Some(name) = p.get("section").and_then(Value::as_str) else { + return Err(format!("{command}: `section` is needed ({})", SidebarSection::ids())); + }; + let section = SidebarSection::from_id(name).ok_or_else(|| format!("{command}: unknown section `{name}` ({})", SidebarSection::ids()))?; + if !section.available() { + return Err(format!("{command}: there is no `{name}` section here")); + } + Ok(section) +} + +/// An optional true / false parameter: absent or null is `None`, anything else is refused. +fn bool_param(command: &str, p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(other) => Err(format!("{command}: `{key}` must be true or false, not {other}")), + } +} + +/// What Shift+H says it switched to (the pins themselves may be off screen). +fn tool_overlay_toast(mode: crate::state::ToolOverlay) -> String { + use crate::state::ToolOverlay; + crate::i18n::tr(match mode { + ToolOverlay::Auto => "Tool Overlay: Auto", + ToolOverlay::Always => "Tool Overlay: Always", + ToolOverlay::Selected => "Tool Overlay: Selected", + ToolOverlay::Never => "Tool Overlay: Never", + }) + .to_string() +} + +fn appearance_json(app: &LightcraftApp) -> Value { + let s = &app.ui.settings; + json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) +} + +/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). +/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { + use crate::state::AppearanceMode; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), + Some(v) => match v.as_str() { + Some("auto") => AppearanceMode::Auto, + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), + }, + }; + app.ui.settings.appearance_mode = mode; + Ok(appearance_json(app)) +} + +/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). +fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { + let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); + let Some(id) = p.get("theme").and_then(Value::as_str) else { + return Err(format!("view.theme: `theme` is required ({ids})")); + }; + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { + return Err(format!("view.theme: unknown theme `{id}` ({ids})")); + }; + app.ui.settings.select_theme(kind); + Ok(appearance_json(app)) +} + +/// Handle UI commands; `None` means "not a UI command — send it to the engine". +pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { + if matches!(id, "ui.dock" | "ui.dock.reset") { + return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + } + if matches!(id, "library.inspectLightroom" | "library.importLightroom") { + // the app's own context: a fresh one's repaints reach no window and its clock starts at zero + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + return Some(crate::lightroom_import::command(app, id, p, &ctx)); + } + if id == "photo.reload" { + // the files are read in the background (a raw's decode takes a second or more) + return crate::reload::command(app, p); + } + if let Some(language) = language_from_command(id) { + app.ui.language = language; + // Immediately, not on the next frame: the reply and anything else run this frame + // (menus rebuilt from it, toasts) are already in the new language. + crate::i18n::set_language(app.ui.language); + return Some(Ok(json!(app.ui.language))); + } + // the app's own context: a toast's time comes from its clock (a fresh context's clock is at 0, + // and a toast set by it was long over by the app's clock: it never showed) + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + let r: Result = match id { + "view.photoGrid" => { + app.ui.view = ViewMode::PhotoGrid; + Ok(Value::Null) + } + "view.squareGrid" => { + app.ui.view = ViewMode::SquareGrid; + Ok(Value::Null) + } + "view.gridInfo" => { + // {info?: filename | exposure | date} (cycles when omitted) + let next = match p.get("info").and_then(Value::as_str) { + Some(i @ ("filename" | "exposure" | "date")) => i.to_string(), + Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))), + None => match app.ui.grid_info.as_str() { + "filename" => "exposure".into(), + "exposure" => "date".into(), + _ => "filename".into(), + }, + }; + app.ui.grid_info = next; + app.ui.show_filenames = true; + // the caption is drawn on Square Grid cells only: from Photo Grid, show it there + if app.ui.view == ViewMode::PhotoGrid { + app.ui.view = ViewMode::SquareGrid; + } + Ok(json!({"info": app.ui.grid_info})) + } + "tool.keywordPainter" => { + // {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops + app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string); + if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + if let Some(k) = app.ui.keyword_painter.clone() { + app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k)); + } + Ok(json!({"keyword": app.ui.keyword_painter})) + } + "view.secondWindow" => { + app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window); + Ok(json!({"show": app.ui.second_window})) + } + "view.photoCounts" => { + app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); + Ok(json!({"show": app.ui.show_counts})) + } + "view.sidebarSection" => { + // {section, show?, collapsed?}: show or hide a section of the left sidebar, fold or + // unfold it; with neither, a shown section is hidden and a hidden one shown + let args = sidebar_section_param(id, p).and_then(|s| Ok((s, bool_param(id, p, "show")?, bool_param(id, p, "collapsed")?))); + let (section, show, collapsed) = match args { + Ok(args) => args, + Err(e) => return Some(Err(e)), + }; + let layout = &mut app.ui.sidebar; + match (show, collapsed) { + (None, None) => layout.set_hidden(section, !layout.is_hidden(section)), + _ => { + if let Some(show) = show { + layout.set_hidden(section, !show); + } + if let Some(collapsed) = collapsed { + layout.set_collapsed(section, collapsed); + } + } + } + Ok(json!({"section": section.id(), "show": !layout.is_hidden(section), "collapsed": layout.is_collapsed(section)})) + } + "view.sidebarMoveSection" => { + // {section, direction: up | down} one place among the shown sections, or + // {section, before:
| null} just above another section (null: last) + let section = match sidebar_section_param(id, p) { + Ok(s) => s, + Err(e) => return Some(Err(e)), + }; + let layout = &mut app.ui.sidebar; + let moved = match (p.get("direction"), p.get("before")) { + (Some(d), None) => match d.as_str() { + Some("up") => layout.move_by(section, true), + Some("down") => layout.move_by(section, false), + _ => return Some(Err(format!("{id}: `direction` must be \"up\" or \"down\", not {d}"))), + }, + (None, Some(Value::Null)) => layout.move_before(section, None), + (None, Some(b)) => { + let before = b.as_str().and_then(crate::sidebar::SidebarSection::from_id); + let Some(before) = before else { + return Some(Err(format!("{id}: `before` must be a section ({}) or null, not {b}", crate::sidebar::SidebarSection::ids()))); + }; + layout.move_before(section, Some(before)) + } + _ => return Some(Err(format!("{id}: give either `direction` (up|down) or `before` (a section, or null for last)"))), + }; + let order: Vec<&str> = layout.sections().iter().map(|s| s.id.id()).collect(); + Ok(json!({"section": section.id(), "moved": moved, "order": order})) + } + "view.sidebarReset" => { + // every section shown and open, in the usual order + app.ui.sidebar = Default::default(); + Ok(json!({"sidebar": app.ui.sidebar})) + } + "view.appearance" => appearance_command(app, p), + "view.theme" => theme_command(app, p), + "library.selectOnlyActive" => { + let Some(active) = app.session.active() else { return Some(Err("no active photo".into())) }; + app.run("library.select", json!({"ids": [active.0], "active": active.0})) + } + "view.gridToggle" => { + app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; + Ok(Value::Null) + } + "view.detail" => { + app.ui.view = ViewMode::Detail; + Ok(Value::Null) + } + "view.compare" => crate::panels::compare::enter_compare(app), + "view.focusPoint" => { + // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. + app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); + Ok(json!({"on": app.ui.settings.focus_point})) + } + "view.zoomToFocus" => { + // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. + app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); + Ok(json!({"on": app.ui.settings.zoom_to_focus})) + } + "view.faceBoxes" => { + app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); + Ok(json!({"show": app.ui.face_boxes})) + } + "view.people" => { + // Everyone; the page just left stays one click away (the chip next to the title) + if let Some(name) = app.ui.person_page.take() { + app.ui.last_person = Some(name); + } + app.ui.person_from = None; + app.ui.view = ViewMode::People; + Ok(json!({"people": app.session.catalog.people().len()})) + } + "view.person" => { + // {name}: one person's page in the People view: their faces, and the faces that look like them + let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { + return Some(Err("view.person: missing `name`".into())); + }; + app.ui.view = ViewMode::People; + app.ui.person_from = None; + app.ui.person_page = Some(name.to_string()); + Ok(json!({"person": name})) + } + "view.survey" => { + app.ui.view = ViewMode::Survey; + Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) + } + "view.reference" => { + // the reference: the one set before, else the active photo (the next one becomes active) + let vis = app.session.visible_cloned(); + let live = |id: u64| crate::panels::compare::live(app, lightcraft_catalog::PhotoId(id)); + let active = app.session.active().map(|a| a.0); + let reference = app.ui.reference.filter(|r| live(*r)).or_else(|| active.filter(|a| live(*a))); + let Some(r) = reference else { + return Some(Err(match active { + Some(a) => format!("photo {a} is in Recently Deleted: restore it to use it as the reference"), + None => "select a photo to use as the reference".into(), + })); + }; + app.ui.reference = Some(r); + if app.session.active().is_none_or(|a| a.0 == r) + && let Some(i) = vis.iter().position(|x| x.0 == r) + && let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))) + { + let _ = app.session.execute("library.select", &json!({"ids": [next.0]})); + } + app.ui.view = ViewMode::Reference; + Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) + } + "photo.setReference" => { + // only an absent (or null) `id` means the active photo; any other non-id is refused + let id = match p.get("id").filter(|v| !v.is_null()) { + Some(v) => match v.as_u64() { + Some(id) => Some(id), + None => return Some(Err(format!("photo.setReference: `id` must be a photo id, not {v}"))), + }, + None => app.session.active().map(|a| a.0), + }; + let Some(id) = id else { return Some(Err("select a photo to use as the reference".into())) }; + match app.session.catalog.photo(lightcraft_catalog::PhotoId(id)) { + None => return Some(Err(format!("no photo {id}"))), + Some(p) if p.deleted => return Some(Err(format!("photo {id} is in Recently Deleted: restore it to use it as the reference"))), + Some(_) => {} + } + app.ui.reference = Some(id); + Ok(json!({"reference": id})) + } + "compare.swap" => crate::panels::compare::swap(app), + "compare.set" => { + let Some(id) = p.get("id").and_then(Value::as_u64) else { return Some(Err("compare.set needs `id`".into())) }; + crate::panels::compare::set_photo(app, lightcraft_catalog::PhotoId(id), p.get("side").and_then(Value::as_str)) + } + "compare.makeSelect" => 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" })); + 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.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")); + } + r + } + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => { + let now = app.tick_now(); + crate::panels::slideshow::command(app, id, now) + } + "view.slideshowOptions" => { + app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); + Ok(Value::Null) + } + "view.fullScreenPreview" => { + 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 { + app.ui.fullscreen = true; + app.ui.zoom = Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + } + Ok(json!({"fullscreen": app.ui.fullscreen})) + } + "view.lightsOut" => { + // {mode?: on | dim | off} (cycles when omitted) + match p.get("mode").and_then(Value::as_str) { + Some(m) => serde_json::from_value(json!(m)).map_err(|_| format!("unknown lights-out mode: {m} (on|dim|off)")), + None => Ok(app.ui.lights_out.next()), + } + .map(|mode| { + app.ui.lights_out = mode; + json!({"lightsOut": mode}) + }) + } + "view.enterFullScreen" => { + // applied by the host's frame logic (it knows the window's current state) + let on = p.get("on").and_then(Value::as_bool); + app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); + Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) + } + "view.infoOverlay" => { + app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { + Some(m) => match serde_json::from_value(json!(m)) { + Ok(v) => v, + Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), + }, + None => app.ui.info_overlay.next(), + }; + let label = match app.ui.info_overlay { + crate::state::InfoOverlay::Off => "Info Overlay Off", + crate::state::InfoOverlay::Basic => "Info Overlay: File", + crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", + }; + 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.chooseLayoutImage" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(path) => path.trim().to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Choose Layout Image…"), "PNG", &["png"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => match paths.first() { + Some(path) => path.trim().to_string(), + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform; supply path".into())), + } + } + }; + if path.is_empty() { + return Some(Err("view.chooseLayoutImage: path is empty".into())); + } + let li = &mut app.ui.layout_image; + li.path = path; + li.show = true; + li.revision = li.revision.wrapping_add(1); + app.toast(&ctx, "Layout Image On"); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImage" => { + let show = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.layout_image.show); + // nothing chosen yet: showing it chooses one first + if show && app.ui.layout_image.path.trim().is_empty() { + return run_ui_command(app, "view.chooseLayoutImage", &json!({})); + } + app.ui.layout_image.show = show; + app.toast(&ctx, if show { "Layout Image On" } else { "Layout Image Off" }); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImageOpacity" | "view.layoutImageMatte" => { + let key = if id == "view.layoutImageOpacity" { "opacity" } else { "matte" }; + let Some(v) = p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) else { + return Some(Err(format!("{id}: {key} (0..1) is required"))); + }; + let v = v.clamp(0.0, 1.0) as f32; + if key == "opacity" { + app.ui.layout_image.opacity = v; + } else { + app.ui.layout_image.matte = v; + } + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.navigator" => { + app.ui.navigator = !app.ui.navigator; + Ok(json!({"navigator": app.ui.navigator})) + } + "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) { + let tabs: Vec<&str> = crate::panels::settings::TABS.iter().map(|(id, _)| *id).collect(); + return Some(Err(format!("unknown settings tab `{tab}` ({})", tabs.join("|")))); + } + app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); + Ok(Value::Null) + } + "app.openLibrary" => crate::panels::settings::open_library(app, p), + "app.newLibrary" => crate::panels::settings::new_library(app, p), + "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), + "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), + "app.displayProfile" => crate::panels::settings::display_profile(app, p), + "file.backupLibrary" | "file.restoreLibrary" => { + let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; + match action { + Some(f) => f(&mut app.session), + None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()), + } + } + "view.filmstrip" => { + app.ui.filmstrip = !app.ui.filmstrip; + Ok(Value::Null) + } + "view.frameRate" => { + // {show?: bool} (toggles when omitted) + app.ui.frame_rate = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.frame_rate); + Ok(json!({"show": app.ui.frame_rate})) + } + "view.leftPanel" => { + app.ui.left_panel = !app.ui.left_panel; + Ok(Value::Null) + } + "view.rightPanel" => { + // Classic's F8: hide the right panel, and bring back the same one next time (Edit when + // none was hidden). Hiding a tool ends it, as closing its panel does. + if app.ui.right != RightPanel::None && crate::docking::exposed(app, crate::docking::Panel::Tools) { + app.ui.right_hidden = app.ui.right; + app.ui.right = RightPanel::None; + if app.ui.tool != "wbPicker" { + app.ui.tool.clear(); + } + let _ = app.session.end_interaction(); + } else if app.ui.right == RightPanel::None { + let p = match std::mem::take(&mut app.ui.right_hidden) { + RightPanel::None => RightPanel::Edit, + p => p, + }; + app.ui.right = p; + if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + if p == RightPanel::Remove && app.ui.tool.is_empty() { + app.ui.tool = "remove".into(); + } + } + // A right panel that is open but not showing (minimized, or behind another tab) + // keeps its mode; reveal_command brings it to the front. + Ok(json!({"open": app.ui.right != RightPanel::None})) + } + "view.beforeAfter" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; + Ok(Value::Null) + } + "view.beforeAfterSplit" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::Split { BeforeAfter::Off } else { BeforeAfter::Split }; + Ok(Value::Null) + } + "view.beforeAfterTopBottom" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::TopBottom { BeforeAfter::Off } else { BeforeAfter::TopBottom }; + Ok(Value::Null) + } + "view.beforeAfterSplitTopBottom" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::SplitTopBottom { BeforeAfter::Off } else { BeforeAfter::SplitTopBottom }; + Ok(Value::Null) + } + "view.showOriginal" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original }; + Ok(Value::Null) + } + "view.zoomFit" => { + app.ui.zoom = Zoom::Fit; + Ok(Value::Null) + } + "view.zoom100" => { + app.ui.zoom = Zoom::Percent(100.0); + Ok(Value::Null) + } + "view.zoomToggle" => { + // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus + // point when the photo has one (View ▸ Zoom to Focus Point) + let zoom_in = app.ui.zoom == Zoom::Fit; + app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; + app.ui.zoom_anim = true; + if zoom_in + && app.ui.settings.zoom_to_focus + && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) + { + app.ui.pan = pan; + return Some(Ok(json!({"focus": [pan.0, pan.1]}))); + } + Ok(Value::Null) + } + "view.clickZoom" => { + // {ratio?: 1|2|3|4|8} → {ratio} + if let Some(r) = p.get("ratio").and_then(Value::as_f64) { + let pct = (r * 100.0).round() as u32; + if !crate::state::CLICK_ZOOMS.contains(&pct) { + return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)"))); + } + app.ui.click_zoom = pct; + } + Ok(json!({"ratio": app.ui.click_zoom / 100})) + } + "view.zoomIn" | "view.zoomOut" => { + let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0]; + let cur = match app.ui.zoom { + Zoom::Percent(p) => p, + _ => 25.0, + }; + let next = if id == "view.zoomIn" { + steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0) + } else { + steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0) + }; + app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; + Ok(Value::Null) + } + "view.thumbSizeUp" | "view.thumbSizeDown" => { + // the bottom-bar slider's range, one `THUMB_SIZE_STEP` per call; {} → {size} + if !thumb_size_view(app) { + return Some(Err(format!("{id}: only in Photo Grid, Square Grid or People"))); + } + let step = if id == "view.thumbSizeUp" { crate::state::THUMB_SIZE_STEP } else { -crate::state::THUMB_SIZE_STEP }; + app.ui.thumb_size = (app.ui.thumb_size + step).clamp(crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); + Ok(json!({"size": app.ui.thumb_size})) + } + "view.navigate" => { + // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). + // Validate the complete request before changing either part of the viewport. + let zoom = match p.get("zoom") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => { + return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into())); + } + Ok(z) => z, + Err(e) => return Some(Err(format!("view.navigate: {e}"))), + }, + None => app.ui.zoom, + }; + let pan = match p.get("pan") { + Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) { + Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y), + _ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())), + }, + None => app.ui.pan, + }; + app.ui.zoom = zoom; + app.ui.pan = pan; + app.ui.zoom_anim = false; + Ok(json!({"zoom": zoom, "pan": pan})) + } + "view.clipping" => { + app.ui.show_clipping = !app.ui.show_clipping; + Ok(Value::Null) + } + "view.visualizeHdr" => { + // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`). + app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize); + Ok(json!({"on": app.ui.hdr_visualize})) + } + "view.softProof" => { + // {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.histogram" => { + app.ui.histogram = !app.ui.histogram; + Ok(Value::Null) + } + "view.maskOverlay" => { + app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); + Ok(json!({"maskOverlay": app.ui.mask_overlay})) + } + "view.maskOverlayAll" => { + app.ui.mask_overlay_all = !app.ui.mask_overlay_all; + Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all})) + } + "view.maskOverlayMode" => { + use lightcraft_engine::pipeline::MaskView; + let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + let next = match p.get("mode").and_then(Value::as_str) { + Some(m) => match MaskView::parse(m) { + Some(v) => v, + None => { + let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); + return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); + } + }, + None => cur.next(), + }; + app.ui.mask_overlay_mode = next.name().into(); + app.ui.mask_overlay = true; + app.toast(&ctx, next.label()); + Ok(json!({"mode": next.name()})) + } + "view.maskOverlayColor" => { + // no params: the next of the panel's swatch colours + if p.get("color").is_none() && p.get("opacity").is_none() { + let all = crate::panels::masking::OVERLAY_COLORS; + let i = all.iter().position(|c| *c == app.ui.mask_overlay_color).map_or(0, |i| (i + 1) % all.len()); + app.ui.mask_overlay_color = all[i]; + } + if let Some(c) = p.get("color") { + match parse_rgb(c) { + Some(rgb) => app.ui.mask_overlay_color = rgb, + None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), + } + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; + } + let [r, g, b] = app.ui.mask_overlay_color; + Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) + } + "brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => { + let (k, df) = match id { + "brush.smaller" => (1.0 / 1.2, 0.0), + "brush.larger" => (1.2, 0.0), + "brush.featherLess" => (1.0, -10.0), + _ => (1.0, 10.0), + }; + Ok(adjust_brush(app, k, df)) + } + "view.maskPins" => { + app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); + Ok(json!({"maskPins": app.ui.mask_pins})) + } + "view.infoSelectedPhotos" => { + // {on?}; no params toggles. On: the Info panel shows what the selected photos share. + let on = match p.get("on") { + None | Some(Value::Null) => !app.ui.settings.info_selected_photos, + Some(Value::Bool(on)) => *on, + Some(v) => return Some(Err(format!("view.infoSelectedPhotos: `on` must be true or false, not {v}"))), + }; + app.ui.settings.info_selected_photos = on; + Ok(json!({"on": on})) + } + "view.toolOverlay" => { + use crate::state::ToolOverlay; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.tool_overlay.next(), + Some(v) => match v.as_str().and_then(ToolOverlay::parse) { + Some(m) => m, + None => return Some(Err(format!("view.toolOverlay: unknown mode {v} (auto|always|selected|never)"))), + }, + }; + app.ui.tool_overlay = mode; + app.toast(&ctx, tool_overlay_toast(mode)); + Ok(json!({"mode": mode.name()})) + } + "view.visualizeSpots" => { + // like Lightroom's A: opens the Remove tool with the view on, or toggles it there + if app.ui.right == RightPanel::Remove { + app.ui.visualize_spots = !app.ui.visualize_spots; + } else { + app.ui.right = RightPanel::Remove; + app.ui.visualize_spots = true; + } + Ok(Value::Null) + } + // A under the Crop panel: the aspect lock (the panel's button runs the same toggle) + "crop.lockAspect" => app.run("crop.aspect", json!({"aspect": "toggle"})), + "view.cropOverlay" => { + use crate::state::CropOverlay::*; + app.ui.crop_overlay = match app.ui.crop_overlay { + Thirds => Grid, + Grid => Golden, + Golden => Diagonal, + Diagonal => Triangle, + Triangle => Spiral, + Spiral => None, + None => Thirds, + }; + Ok(json!({"overlay": app.ui.crop_overlay})) + } + "view.cropOverlayOrientation" => { + app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4; + Ok(json!({"orientation": app.ui.crop_overlay_orient})) + } + "local.addRoot" => { + // a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes + let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) }; + let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into()); + let abs = if abs.is_empty() { path.to_string() } else { abs }; + if !std::path::Path::new(&abs).is_dir() { + return Some(Err(format!("{path}: not a folder"))); + } + use crate::panels::left::same_folder; + if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) { + app.ui.local_roots.push(abs.clone()); + } + app.ui.hidden_locations.retain(|h| !same_folder(h, &abs)); + Ok(json!({"roots": app.ui.local_roots})) + } + "local.hide" => match p.get("path").and_then(Value::as_str) { + Some(path) => { + if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) { + app.ui.hidden_locations.push(path.to_string()); + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } + None => Err("local.hide needs a path".into()), + }, + "local.restoreHidden" => { + // one path, or (no path) every hidden location + match p.get("path").and_then(Value::as_str) { + Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)), + None => app.ui.hidden_locations.clear(), + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } + "view.soloSections" => bool_param(id, p, "on").map(|on| { + app.ui.set_single_panel(on.unwrap_or(!app.ui.single_panel)); + json!({"soloSections": app.ui.single_panel}) + }), + "view.filterBar" => { + app.ui.filter_bar = !app.ui.filter_bar; + if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + Ok(json!({"filterBar": app.ui.filter_bar})) + } + "panel.edit" => { + panel(app, &ctx, RightPanel::Edit, "Edit"); + Ok(Value::Null) + } + "panel.profiles" => { + // toggles between the profile browser and the Edit panel it belongs to + app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) { + RightPanel::Edit + } else { + RightPanel::Profiles + }; + if !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + Ok(json!({"open": app.ui.right == RightPanel::Profiles})) + } + "panel.crop" => { + panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + Ok(Value::Null) + } + "panel.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"); + Ok(Value::Null) + } + "panel.redeye" => { + panel(app, &ctx, RightPanel::RedEye, "Red Eye"); + Ok(Value::Null) + } + "panel.info" => { + panel(app, &ctx, RightPanel::Info, "Info"); + Ok(Value::Null) + } + "panel.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"); + } + 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"); + Ok(Value::Null) + } + "panel.activity" => { + panel(app, &ctx, RightPanel::Activity, "History"); + Ok(Value::Null) + } + "panel.presets" => { + app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets); + if app.ui.presets && app.ui.view != ViewMode::Detail { + app.ui.view = ViewMode::Detail; + } + Ok(Value::Null) + } + "tool.done" => { + // Return commits a tool panel (crop, remove, red eye, masking): back to Edit + use RightPanel::*; + if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) { + let _ = app.session.end_interaction(); + app.ui.tool.clear(); + app.ui.right = Edit; + } + Ok(Value::Null) + } + "panel.close" => { + app.ui.right = RightPanel::None; + app.ui.presets = false; + Ok(Value::Null) + } + 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) { + return Err(format!("{s}: no such Edit section")); + } + let sec = &s["section.".len()..]; + if app.ui.right != RightPanel::Edit { + app.ui.right = RightPanel::Edit; + } + if solo == Some(true) { + app.ui.solo_section(sec); + } else { + app.ui.toggle_section(sec); + } + Ok(Value::Null) + }), + s if s.starts_with("tool.") => { + let tool = &s["tool.".len()..]; + match tool { + "none" => app.ui.tool.clear(), + "guidedUpright" => { + // Crop & Geometry with Guided Upright on, ready to draw guides + app.ui.right = RightPanel::Crop; + app.ui.view = ViewMode::Detail; + let guided = app + .session + .active() + .and_then(|id| app.session.develop_of(id)) + .is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided); + if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) { + return Some(Err(e)); + } + app.ui.tool = "guidedUpright".into(); + } + "brush" => { + // A new mask and Add/Subtract are selection operations, not the brush's + // Erase mode. Paint positively into a component with the requested op. + let create = if p.get("new").and_then(Value::as_bool) == Some(true) { + Some(app.run("mask.add", json!({"kind": "brush"}))) + } else { + p.get("op").map(|op| app.run("mask.addComponent", json!({"kind": "brush", "op": op}))) + }; + if let Some(result) = create { + if let Err(e) = result { + return Some(Err(e)); + } + app.ui.brush_erase = false; + app.ui.mask_overlay = true; + } + app.ui.right = RightPanel::Masking; + app.ui.view = ViewMode::Detail; + app.ui.tool = "brush".into(); + } + "linear" | "radial" => { + app.ui.right = RightPanel::Masking; + app.ui.view = ViewMode::Detail; + app.ui.tool = tool.into(); + return Some(app.session.execute("mask.add", &json!({"kind": tool})).map_err(|e| e.to_string())); + } + "wbPicker" => { + app.ui.right = RightPanel::Edit; + app.ui.view = ViewMode::Detail; + app.ui.tool = "wbPicker".into(); + } + other => return Some(Err(format!("unknown tool `{other}`"))), + } + Ok(Value::Null) + } + "dialog.newFolder" => { + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: true, + parent: parent_param(p), + include_selected: false, + }); + Ok(Value::Null) + } + "dialog.newAlbum" => { + // {name?, parent?, includeSelected? (true)} + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: false, + parent: parent_param(p), + include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true), + }); + Ok(Value::Null) + } + "dialog.autoStack" => { + app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); + Ok(Value::Null) + } + "dialog.allMetadata" => { + let r = match app.session.execute("photo.allMetadata", p) { + Ok(r) => r, + Err(e) => return Some(Err(e.to_string())), + }; + let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default(); + app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() }); + Ok(Value::Null) + } + "dialog.smartAlbum" => { + // {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.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) + } + "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 + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .map(|p| p.date()) + .filter(|d| lightcraft_catalog::dates::shift_iso(d, 0).is_some()) + .map(|d| d.replace('T', " ")) + .unwrap_or_default(); + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); + app.ui.dialog = + Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); + Ok(Value::Null) + } + "dialog.labelNames" => { + let names = + lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect(); + app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() }); + Ok(Value::Null) + } + "dialog.rename" => { + let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); + app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); + Ok(Value::Null) + } + "dialog.createPreset" => { + app.ui.dialog = Some(Dialog::create_preset()); + Ok(Value::Null) + } + "dialog.pasteSettings" => { + let groups = + app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); + app.ui.dialog = Some(Dialog::PasteSettings { groups }); + Ok(Value::Null) + } + "view.focusSearch" => { + // the search field lives in the top bar of the library and detail views alike + app.ui.focus_search = true; + Ok(Value::Null) + } + "dialog.copySettings" => { + let groups = + app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); + app.ui.dialog = Some(Dialog::CopySettings { groups }); + Ok(Value::Null) + } + "dialog.slideshowExport" => { + let format = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => lightcraft_engine::slideshow_export::Format::Jpeg, + Some("video") => lightcraft_engine::slideshow_export::Format::Video, + _ => lightcraft_engine::slideshow_export::Format::Pdf, + }; + app.ui.dialog = Some(Dialog::SlideshowExport { settings: lightcraft_engine::slideshow_export::ExportSettings::for_format(format) }); + Ok(Value::Null) + } + "app.slideshowExport" => crate::slideshow_export::start(app, id, p), + "dialog.contactSheet" => { + app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); + Ok(Value::Null) + } + "dialog.print" => { + app.ui.print = Default::default(); + app.ui.dialog = Some(Dialog::Print); + Ok(Value::Null) + } + "app.print" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_print(app, ¶ms) + } + "app.contactSheet" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Contact Sheet PDF", "PDF", &["pdf"], "Contact Sheet.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_contact_sheet(app, ¶ms) + } + "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); + let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); + // stored settings: a depth the format lacks (saved before #1952) opens at the format's own + let opts = lightcraft_engine::export::ExportOptions::from_json(&lightcraft_engine::export::ExportOptions::known_keys_only(&prev)); + // no previous export: 2048 px long edge; a previous full-size export: full size + let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); + let resize = opts.resize.unwrap_or_default(); + app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, preset_name: String::new(), limit_kb: u("limitKb", 0) as u32, dir }); + Ok(Value::Null) + } + "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), + "merge.hdrBatch" => crate::merge::start_batch(app, p), + "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), + "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), + "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), + "dialog.faceModel" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => app.services.pick_model_file.as_mut().and_then(|f| f().into_iter().next()), + }; + match path { + Some(path) => crate::panels::faces::open_dialog(app, &path), + None => Ok(Value::Null), + } + } + "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), + "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), + "app.openSamModelFolder" => crate::panels::dialogs::show_model_folder(app), + "app.about" => { + app.ui.dialog = Some(Dialog::About); + Ok(Value::Null) + } + "app.checkForUpdates" => 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), + "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 }); + Ok(Value::Null) + } + "app.whatsNew" => { + app.ui.dialog = Some(Dialog::WhatsNew); + Ok(json!({"text": crate::panels::dialogs::WHATS_NEW})) + } + "app.systemInfo" => { + 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.shortcuts" => { + app.ui.dialog = Some(Dialog::Shortcuts); + Ok(Value::Null) + } + "app.setShortcut" => crate::shortcuts::set_shortcut(app, p), + "app.resetShortcuts" => { + app.ui.settings.keymap.clear(); + Ok(Value::Null) + } + "library.browse" if !cfg!(target_arch = "wasm32") => { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before browsing folders".into())); + } + // listed and read in the background (see `import::browse`) + let path = p.get("path").and_then(Value::as_str)?; + crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) + } + "file.addPhotos" => { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before adding photos".into())); + } + 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(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), + } + } + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + // review first: the import dialog lists what was found + let r = crate::import::open(app, paths); + import_into_album(app, p, r) + } + "file.importLightroom" => { + // where the catalog goes (#2672): checked before a file dialog opens + let req = match crate::lightroom_destination::Request::parse(p) { + Ok(req) => req, + Err(e) => return Some(Err(e)), + }; + let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { + Some(path) => Some(path), + None => { + let req = + PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => crate::lightroom_destination::import(app, &path, &req), + None => Ok(Value::Null), + } + } + "app.quit" => { + app.ui.quit = true; + Ok(Value::Null) + } + "dialog.saveMetadataPreset" => { + // from the active photo's copyright, creator and place + crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name"); + Ok(Value::Null) + } + "file.findMissing" => { + 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.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) { + Some(x) => x.to_string(), + None => match lightcraft_engine::devices::devices_now().into_iter().next() { + Some(d) => d.path, + None => return Some(Err("no camera or memory card found".into())), + }, + }; + let r = crate::import::open(app, vec![path]); + // only the scan just started (an error means another scan is running) + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.copy = true; + } + import_into_album(app, p, r) + } + "file.addFolder" => { + // a folder (searched recursively) into the import review + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |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, + }, + }; + match path { + Some(path) => { + let r = crate::import::open(app, vec![path]); + import_into_album(app, p, r) + } + None => 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); + } + "dialog.saveMaskEffect" => { + if app.session.active_mask.is_none() { + return Some(Err("select a mask first".into())); + } + crate::panels::mask_effects::open_save(app); + Ok(Value::Null) + } + "dialog.manageMaskEffects" => { + crate::panels::mask_effects::open_manage(app); + Ok(Value::Null) + } + "file.exportMaskEffects" => { + 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.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) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); + let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], 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}); + if let Some(g) = group { + params["group"] = json!(g); + } + if let Some(ids) = p.get("ids") { + params["ids"] = ids.clone(); + } + let r = app.session.execute("preset.export", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + return Some(r); + } + "file.importCurvePresets" => { + 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); + } + "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)), + "app.openLogFolder" => open_log_folder(app), + "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { + let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); + crate::links::open(app, url) + } + "app.exportPrevious" => match app.session.last_export.clone() { + Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)), + None => Err("nothing exported yet — use Export…".into()), + }, + _ => return None, + }; + Some(r) +} diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs new file mode 100644 index 000000000..7236b5528 --- /dev/null +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -0,0 +1,280 @@ +use super::*; + +/// 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. +pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ + ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), + // The picker order (`Locale::for_picker`): alphabetical by the language's own name after + // English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal. + ("app.language.indonesian", crate::i18n::Locale::Id.name(), None, "Edit>Language"), + ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), + ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), + ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), + ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), + ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), + ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), + ("app.language.finnish", crate::i18n::Locale::Fi.name(), None, "Edit>Language"), + ("app.language.swedish", crate::i18n::Locale::Sv.name(), None, "Edit>Language"), + ("app.language.vietnamese", crate::i18n::Locale::Vi.name(), None, "Edit>Language"), + ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), + ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), + ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), + ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), +]; + +/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from +/// this file, so an id listed in `docs/parity-checklist.md` is checked wherever it is declared. +pub fn ui_commands() -> impl Iterator { + LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) +} + +/// The language a Language-menu command selects, if the id is one. The engine and the UI both go +/// through here, so the menu, the settings row and the control channel agree on the mapping. +pub fn language_from_command(id: &str) -> Option { + match id { + "app.language.english" => Some(crate::i18n::Locale::En), + "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), + "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), + "app.language.japanese" => Some(crate::i18n::Locale::Ja), + "app.language.portuguese" => Some(crate::i18n::Locale::PtBr), + "app.language.french" => Some(crate::i18n::Locale::Fr), + "app.language.spanish" => Some(crate::i18n::Locale::Es), + "app.language.german" => Some(crate::i18n::Locale::De), + "app.language.russian" => Some(crate::i18n::Locale::Ru), + "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.polish" => Some(crate::i18n::Locale::Pl), + "app.language.czech" => Some(crate::i18n::Locale::Cs), + "app.language.italian" => Some(crate::i18n::Locale::It), + "app.language.georgian" => Some(crate::i18n::Locale::Ka), + "app.language.swedish" => Some(crate::i18n::Locale::Sv), + "app.language.finnish" => Some(crate::i18n::Locale::Fi), + "app.language.indonesian" => Some(crate::i18n::Locale::Id), + "app.language.vietnamese" => Some(crate::i18n::Locale::Vi), + _ => None, + } +} + +pub const UI_COMMANDS: &[UiCommand] = &[ + ("ui.dock", "Arrange Panel", None, ""), + ("ui.dock.reset", "Reset Panel Layout", None, "Window"), + ("modelSetup.cancel", "Cancel Pending Photo Action", None, ""), + ("view.photoGrid", "Photo Grid", None, "View"), + ("view.squareGrid", "Square Grid", None, "View"), + // G: Photo Grid ↔ Square Grid (from other views: the photo grid) + ("view.gridToggle", "Grid", Some("G"), ""), + ("library.selectOnlyActive", "Select Only Active Photo", Some("Cmd+Shift+D"), "Edit"), + ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), + ("view.detail", "Detail View", Some("D"), "View"), + ("view.compare", "Compare", Some("Shift+C"), "View"), + ("view.survey", "Survey", Some("N"), "View"), + ("view.people", "People", None, "View"), + ("view.person", "Show Person", None, ""), + ("view.faceBoxes", "Face Boxes", None, "View"), + ("view.focusPoint", "Focus Point", Some("Alt+F"), "View"), + ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), + ("view.reference", "Reference View", Some("Shift+R"), "View"), + ("photo.setReference", "Set as Reference Photo", None, ""), + ("compare.swap", "Swap Compare Photos", None, "View"), + ("compare.set", "Show in Compare", None, ""), + ("compare.makeSelect", "Make Candidate the Select", None, "View"), + ("view.autoAdvance", "Auto Advance", None, "Photo"), + ("view.filmstrip", "Filmstrip", Some("/"), "View"), + ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), + ("view.rightPanel", "Right Panel", None, "View"), + ("view.beforeAfter", "Before/After Side by Side", Some("Y"), "View"), + ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), + ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), + ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), + ("view.showOriginal", "Show Original", Some("\\"), "View"), + ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), + ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), + ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), + // the ratio a click (and Z / Space) zooms to + ("view.clickZoom", "Click Zoom Ratio", None, ""), + ("view.navigate", "Set Image Zoom and Pan", None, ""), + ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), + ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), + // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key + ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), + ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), + ("view.clipping", "Clipping", Some("J"), "View"), + ("view.visualizeHdr", "Visualize HDR", None, "View"), + // in grids S expands/collapses stacks (the engine command it shadows) + ("view.softProof", "Soft Proofing", Some("S"), "View"), + ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), + ("view.maskOverlay", "Mask Overlay", Some("O"), "View"), + // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one + ("view.maskOverlayAll", "All Mask Overlays", Some("Cmd+Shift+O"), "View"), + // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) + ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), + ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), + ("view.maskPins", "Mask Pins", None, "View"), + // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next + // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool + // Overlay and the Remove panel list the modes. + ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), + ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), + // A while the Crop panel is open: the aspect lock as a first-class row. Visualize Spots keeps + // the key outside the panel; the two are a contextual pair in shortcuts::handle + ("crop.lockAspect", "Lock Aspect Ratio", Some("A"), ""), + ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), + ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), + ("view.back", "Back to Grid", Some("Escape"), ""), + ("tool.done", "Done", Some("Enter"), ""), + ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), + ("local.addRoot", "Add Folder to Local", None, ""), + ("local.hide", "Remove from Local", None, ""), + ("local.restoreHidden", "Show Hidden Local Locations", None, ""), + // {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos) + ("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""), + ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), + ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), + ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), + ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), + ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), + ("view.layoutImage", "Layout Image", Some("Cmd+Alt+O"), "View"), + ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), + ("view.layoutImageMatte", "Layout Image Matte", None, ""), + ("view.navigator", "Navigator", None, "View"), + ("panel.edit", "Edit Panel", Some("E"), "Window"), + ("panel.profiles", "Profile Browser", None, "Window"), + ("panel.crop", "Crop & Rotate", Some("C"), "Window"), + ("panel.remove", "Remove", Some("H"), "Window"), + ("panel.masking", "Masking", Some("M"), "Window"), + ("panel.redeye", "Red Eye", None, "Window"), + ("panel.presets", "Presets", Some("Shift+P"), "Window"), + ("panel.info", "Info", Some("I"), "Window"), + ("panel.keywords", "Keywords", Some("K"), "Window"), + // the Keywords panel with the cursor in its Add keyword box + ("panel.addKeywords", "Add Keywords", Some("Cmd+K"), ""), + ("panel.versions", "Versions", Some("Shift+V"), "Window"), + ("panel.activity", "History", None, "Window"), + ("panel.close", "Close Panel", None, ""), + // {solo?}: true opens just that section (⌥-click on its header); otherwise opens or closes it + ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), + ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), + ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), + ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), + ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), + ("section.calibration", "Calibration", None, ""), + // {on?}; no params toggles. Opening one Edit section closes the others. + ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), + ("tool.brush", "Brush", Some("B"), "Window>Tools"), + ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), + ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), + ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), + ("tool.none", "No Tool", None, ""), + // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) + ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), + ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), + ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), + ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), + ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), + ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), + ("dialog.smartAlbum", "New Smart Album…", None, "File"), + ("view.photoCounts", "Photo Counts", None, "View"), + ("view.sidebarSection", "Show Sidebar Section", None, ""), + ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), + ("view.sidebarReset", "Reset Sidebar Sections", None, ""), + ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), + ("view.slideshowOptions", "Slideshow Options…", None, "View"), + ("dialog.slideshowExport", "Export Slideshow…", None, "View"), + ("app.slideshowExport", "Export Slideshow", None, ""), + // the slideshow's playback controls (Space, ←/→ and Esc while it plays) + ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), + ("slideshow.next", "Next Slide", None, ""), + ("slideshow.previous", "Previous Slide", None, ""), + ("slideshow.end", "End Slideshow", None, ""), + ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), + ("tool.keywordPainter", "Keyword Painter", None, ""), + ("view.gridInfo", "Grid Info", None, ""), + // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the + // top bar's appearance button). View ▸ Appearance lists the modes. + ("view.appearance", "Next Appearance Mode", None, ""), + // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. + ("view.theme", "Theme", None, ""), + ("dialog.allMetadata", "All Metadata…", None, "Photo"), + ("dialog.faceModel", "Add Face Model…", None, ""), + ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), + ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), + ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), + ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), + ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), + ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), + ("dialog.export", "Export…", None, "File"), + ("dialog.print", "Print…", Some("Cmd+P"), "File"), + ("app.print", "Print to PDF", None, ""), + ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), + ("app.contactSheet", "Export Contact Sheet PDF", None, ""), + ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), + ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), + // {ids?, app?}: the original files themselves (a raw stays a raw: no render, no copy), opened + // in the other editor, e.g. a raw developer + ("photo.openOriginalInExternal", "Open Original in Other Editor (RAW, no copy)", None, "Photo>Edit In"), + ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), + ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), + ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), + ("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"), + ("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"), + ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), + ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), + ("file.addFolder", "Import from Folder…", None, "File"), + // {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or + // Settings) says (#2672, `lightroom_destination`) + ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), + ("file.addFromDevice", "Import from Device", None, ""), + ("file.findMissing", "Find Missing Photos…", None, "File"), + ("file.backupLibrary", "Back Up Library…", None, "File"), + ("file.restoreLibrary", "Restore Library from Backup…", None, "File"), + ("photo.locate", "Locate Missing File…", None, ""), + ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), + ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), + ("file.importPresets", "Import Profiles & Presets…", None, "File"), + ("file.exportPresets", "Export Presets…", None, "File"), + // Masking panel ▸ Effect menu + ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), + ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), + ("file.exportMaskEffects", "Export Effect Presets…", None, ""), + ("file.importKeywords", "Import Keywords…", None, "File"), + ("file.exportKeywords", "Export Keywords…", None, "File"), + // Edit panel ▸ Curve ▸ Point Curve dropdown + ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), + ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), + ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), + ("app.newLibrary", "New Library…", None, "File"), + ("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"), + ("app.recentLibraries", "Recent Libraries", None, ""), + ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), + // Settings ▸ Display + ("app.displayProfile", "Display Profile", None, ""), + ("app.about", "About LightCraft", None, "Help"), + ("app.checkForUpdates", "Check for Updates…", None, "Help"), + ("app.systemInfo", "System Info…", None, "Help"), + ("app.openLogFolder", "Open Log Folder", None, "Help"), + // the frame-rate display (`frame_rate`): a debugging aid, never saved + ("view.frameRate", "Frame Rate", None, "Help>Debug"), + ("app.whatsNew", "What's New", None, "Help"), + ("dialog.cull", "Assisted Culling…", None, "Photo"), + ("app.help", "LightCraft Help", Some("F1"), "Help"), + ("app.discord", "Join the ArtCraft Discord…", None, "Help"), + ("app.feedback", "Send Feedback…", None, "Help"), + ("app.website", "LightCraft Website", None, "Help"), + ("app.github", "LightCraft on GitHub", None, "Help"), + ("app.artcraft", "ArtCraft Website", None, "Help"), + ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), + ("app.setShortcut", "Set Keyboard Shortcut", None, ""), + ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), + ("app.export", "Export Now", None, ""), + ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("app.showInFolders", "Show in Folders", None, "Photo"), + ("app.openSamModelFolder", "Show in File Manager", None, ""), + ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), + ("dialog.labelNames", "Edit Color Label Names…", None, ""), + ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), + ("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"), + ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), +]; diff --git a/crates/ui-egui/src/model_setup.rs b/crates/ui-egui/src/model_setup.rs index bb088eaa9..78587697e 100644 --- a/crates/ui-egui/src/model_setup.rs +++ b/crates/ui-egui/src/model_setup.rs @@ -91,13 +91,15 @@ pub(crate) fn intercept(app: &mut LightcraftApp, command: &str, params: &Value) })) } +/// Show the pending model-setup note for `kind` in the model dialog, with a button that cancels +/// the pending action. Does nothing unless a valid denoise setup is waiting. pub(crate) fn notice(app: &mut LightcraftApp, ui: &mut egui::Ui, kind: &str) { if kind != "denoise" || !app.model_setup.denoise.as_ref().is_some_and(|r| r.valid(app)) { return; } ui.add(egui::Label::new(tr("After installation, AI Denoise will turn on for the requesting photo.")).wrap()); let r = ui.small_button(tr("Cancel pending action")); - crate::widgets::register(ui.ctx(), "modelSetup:cancel:denoise", r.rect); + crate::widgets::register(ui, "modelSetup:cancel:denoise", r.rect); if r.clicked() { let _ = app.run("modelSetup.cancel", json!({"kind": "denoise"})); } @@ -118,7 +120,12 @@ fn abandoned(app: &mut LightcraftApp) -> bool { !v["downloads"].as_array().into_iter().flatten().any(|d| matches!(d["state"].as_str(), Some("running" | "done" | "installing" | "installed"))) } -pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { +/// Advance a pending model setup. An abandoned setup is cancelled on any call (the +/// abandonment/download probe is not throttled); while another interaction is open it waits, and +/// otherwise it checks the photo's model state at most every 250 ms. A photo that still has no +/// model (or cannot use one) keeps waiting; otherwise denoise is enabled and the outcome reported +/// in the status line. +pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { if app.model_setup.denoise.is_none() { return; } @@ -132,7 +139,6 @@ pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.request_repaint_after(std::time::Duration::from_millis(250)); return; } - let now = ctx.input(|i| i.time); if now < app.model_setup.next_check { return; } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels.rs similarity index 88% rename from crates/ui-egui/src/panels/mod.rs rename to crates/ui-egui/src/panels.rs index a07aee8b2..4c2bed935 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels.rs @@ -7,14 +7,11 @@ pub mod compare; pub mod crop_overlay; pub mod denoise; pub mod detail; -pub mod dialogs; pub mod edit; pub mod empty_library; -pub mod faces; pub mod filterbar; pub mod grid; pub mod histogram; -pub mod keymap; pub mod keyword_list; pub mod keywording; pub mod layout_overlay; @@ -28,14 +25,7 @@ pub mod person; pub mod presets; pub mod profiles; pub mod right; -pub mod rules_editor; -pub mod rules_move; pub mod second; -pub mod settings; -pub mod slide_layout; -pub mod slide_overlays; -pub mod slide_templates; -pub mod slide_titles; pub mod slideshow; pub mod strip; pub mod topbar; @@ -44,6 +34,16 @@ pub mod unnamed; use egui::{Align2, Rect, pos2, vec2}; use crate::LightcraftApp; +pub use crate::dialogs; +pub use crate::dialogs::faces; +pub use crate::dialogs::keymap; +pub use crate::dialogs::rules_editor; +pub use crate::dialogs::rules_editor::rules_move; +pub use crate::dialogs::settings; +pub use crate::dialogs::settings::slide_layout; +pub use crate::dialogs::settings::slide_overlays; +pub use crate::dialogs::settings::slide_templates; +pub use crate::dialogs::settings::slide_titles; use crate::theme::Tokens; /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], @@ -61,13 +61,16 @@ pub fn resizable_side( reserve: f32, add: impl FnOnce(&mut egui::Ui), ) -> Option { - let pid = egui::Id::new(id); + // `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.) + let pid = ui.scope_id().with(id); ui.ctx().data_mut(|d| d.remove::(pid)); let avail = ui.available_rect_before_wrap(); let max = (avail.width() - reserve - crate::state::MIN_PHOTO_WIDTH).clamp(limits.min, limits.max); - let panel = if left { egui::Panel::left(pid) } else { egui::Panel::right(pid) }; + let panel = if left { egui::Panel::left(id) } else { egui::Panel::right(id) }; let r = panel.frame(frame).resizable(true).size_range(limits.min..=max).default_size(limits.clamp(width).min(max)).show(ui, add); - crate::widgets::register(ui.ctx(), format!("panel:{id}"), r.response.rect); + crate::widgets::register(ui, format!("panel:{id}"), r.response.rect); // egui names the edge's drag widget `.with("__resize")`; the width is measured from // the panel's fixed edge to the pointer (contents wider than the panel don't count) if !ui.ctx().is_being_dragged(pid.with("__resize")) { @@ -80,7 +83,7 @@ pub fn resizable_side( /// The HUD toast at the bottom centre of the canvas. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + let now = app.tick_now(); let Some((text, until, label)) = app.ui.toast.clone() else { return }; if now > until { app.ui.toast = None; @@ -89,7 +92,7 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(canvas) = app.canvas_rect else { return }; let t = Tokens::get(ctx); let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32; - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("toast"))); // long messages (a save warning, the web build's notices) wrap within the canvas let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0); let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); @@ -170,7 +173,7 @@ pub fn empty_state(ui: &mut egui::Ui, rect: Rect, title: &str, body: &str, actio let button = if i == 0 { egui::Button::new(label.color(egui::Color32::WHITE)).fill(t.accent) } else { egui::Button::new(label.color(t.text)) }; let resp = ui.put(r, button); - crate::widgets::register(ui.ctx(), a.widget, resp.rect); + crate::widgets::register(ui, a.widget, resp.rect); if resp.clicked() { clicked = Some(a.command); } diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 7fa9570fe..dffdd1c4d 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -73,7 +73,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let mut cancel = None; let mut toggle = false; let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN)); - egui::Area::new(egui::Id::new("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| { // An area lays out in the size it had last frame, and the scroll area would take that as all the room there // is: the stack would never grow when rows are added. Give it the whole height below the top bar instead // (the scroll area keeps to `max_height`). @@ -96,7 +96,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.add_space(4.0); let text = if hidden > 0 { tr_format!("+{n} more", n = hidden) } else { tr("Show less").to_string() }; let r = ui.add(egui::Label::new(RichText::new(text).font(t.font(11.0)).color(t.accent)).sense(Sense::click())); - register(ui.ctx(), "activity:more", r.rect); + register(ui, "activity:more", r.rect); toggle = r.clicked(); } }); @@ -132,7 +132,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); crate::widgets::announce(&resp, crate::i18n::tr("Cancel")); paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); - register(ui.ctx(), format!("activity:cancel:{}", task.id), rect); + register(ui, format!("activity:cancel:{}", task.id), rect); clicked = resp.on_hover_text(tr("Cancel")).clicked(); } }); @@ -142,7 +142,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate()); } }); - register(ui.ctx(), format!("activity:row:{}", task.id), r.response.rect); + register(ui, format!("activity:row:{}", task.id), r.response.rect); clicked } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 415e23d06..0b48d1e34 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -123,7 +123,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: }), flag == f, ); - register(pill_ui.ctx(), format!("flag:{id}"), r); + register(&pill_ui, format!("flag:{id}"), r); let on = flag == f && f != Flag::None; let c = if on { if f == Flag::Reject { t.reject } else { t.pick } @@ -146,9 +146,9 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: return; } let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(copy_width, 30.0)); - let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); + let cresp = ui.interact(copy_r, egui::Id::unique("copy-settings"), Sense::click()); crate::widgets::announce(&cresp, label.to_string()); - register(ui.ctx(), "button:copySettings", copy_r); + register(ui, "button:copySettings", copy_r); ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled }); if cresp.clicked() && active.is_some() { @@ -157,9 +157,9 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: app.toast(ui.ctx(), crate::i18n::tr(msg)); } let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); - let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); + let gresp = ui.interact(gear_r, egui::Id::unique("copy-gear"), Sense::click()); crate::widgets::announce(&gresp, crate::i18n::tr("Copy Settings…")); - register(ui.ctx(), "icon:copyGear", gear_r); + register(ui, "icon:copyGear", gear_r); ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); if gresp.clicked() { @@ -243,7 +243,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo // thumbnail size slider let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); crate::widgets::announce(&resp, crate::i18n::tr("Thumbnail size")); - register(child.ctx(), "slider:thumbSize", r); + register(&child, "slider:thumbSize", r); let p = child.painter(); let y = r.center().y; p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track)); @@ -292,7 +292,7 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { // a view that holds its photos shows what matches now when asked to if app.session.hold_view { let r = ui.button(crate::i18n::tr("Refresh View")); - crate::widgets::register(ui.ctx(), "button:refreshView", r.rect); + crate::widgets::register(ui, "button:refreshView", r.rect); if r.clicked() { let _ = app.run("library.refresh", json!({})); } @@ -368,7 +368,8 @@ mod visual_regressions { harness.run_steps(4); let stem = format!("lightcraft-bottom-{view:?}-{width}-{scale}x"); harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); - let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("bottom_bar")).unwrap().outer_rect; + let panel_id = harness.ctx.viewport_id().root_ui_id().with("bottom_bar"); + let panel = egui::containers::panel::PanelState::load(&harness.ctx, panel_id).unwrap().outer_rect; let controls: serde_json::Map = crate::widgets::take_registry(&harness.ctx) .into_iter() .map(|(id, r)| (id, json!([r.left(), r.top(), r.width(), r.height()]))) diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index e6945db01..c18b3ad72 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -89,7 +89,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { let t = Tokens::get(ui.ctx()); let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEIGHT), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.canvas); - register(ui.ctx(), "filterchips", bar); + register(ui, "filterchips", bar); let font = t.font(12.0); let right = bar.right() - 16.0; let mut x = bar.left() + 20.0; @@ -107,9 +107,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { let text_r = Rect::from_min_max(r.min, pos2(r.right() - 22.0, r.max.y)); ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &label, font.clone(), t.text); let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0)); - let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter")); + let resp = ui.interact(xr, egui::Id::unique(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter")); crate::widgets::announce(&resp, format!("{}: {label}", crate::i18n::tr("Remove this filter"))); - register(ui.ctx(), format!("chip:{i}"), xr); + register(ui, format!("chip:{i}"), xr); let col = if resp.hovered() { t.text } else { t.text_dim }; let m = 3.5; let (a, b) = (xr.center() - vec2(m, m), xr.center() + vec2(m, m)); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 70037a51a..829500da4 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -202,7 +202,7 @@ fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect { fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) { let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); - let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); crate::widgets::announce(&resp, app.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_default()); let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) }; let caption_h = 26.0; @@ -308,7 +308,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let now = ui.input(|i| i.time); // (the wait belongs to the photo in the pane: one moved in starts its own) let rendering = ui.ctx().data_mut(|d| { - let (photo, wait) = d.get_temp_mut_or(egui::Id::new(("compare-rendering", i)), (id, None)); + let (photo, wait) = d.get_temp_mut_or(egui::Id::unique(("compare-rendering", i)), (id, None)); if *photo != id { (*photo, *wait) = (id, None); } @@ -361,9 +361,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo && lr.right() < cap.right() { pt.circle_filled(lr.center(), 5.0, super::grid::label_color(l)); - register(ui.ctx(), format!("cull:{}:label", id.0), lr); + register(ui, format!("cull:{}:label", id.0), lr); } - register(ui.ctx(), format!("cull:{}", id.0), display); + register(ui, format!("cull:{}", id.0), display); (display, resp) } @@ -574,7 +574,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { // remove from the survey (deselect) on hover if resp.hovered() && photos.len() > 1 { let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); - let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text(crate::i18n::tr("Remove from survey")); + let xresp = ui.interact(xr, egui::Id::unique(("survey-x", id.0)), Sense::click()).on_hover_text(crate::i18n::tr("Remove from survey")); crate::widgets::announce(&xresp, crate::i18n::tr("Remove from survey")); ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index bcd77176a..fdb208b51 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -41,9 +41,16 @@ pub struct Ui { /// Called every frame: keeps the engine's denoise work going and asks for a redraw when a photo's picture appears (the /// loupe and the thumbnails then render from it) or while one is being made (the progress). +/// `pump_background` plus the UI-input half, for hosts/tests that have no phase split (the app's +/// own `logic` and `ui_pass` keep them apart so a hidden tick never paces work from stale input). pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { - watch_downloads(app, ctx); - let now = ctx.input(|i| i.time); + let now = app.tick_now(); + pump_ui_input(app, ctx, now); + pump_background(app, ctx, now); +} + +/// The input-reading half: fresh pointer/key activity and its timestamp. UI phase only. +pub fn pump_ui_input(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { let (working, moving) = ctx.input(|i| { let keys = i.events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::MouseWheel { .. })); (i.pointer.any_down() || keys, i.pointer.is_moving()) @@ -54,6 +61,11 @@ pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { if moving || working { app.caches.last_move = now; } +} + +/// Worker results and pacing on monotonic deadlines; safe in `logic`, including while hidden. +pub fn pump_background(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { + watch_downloads(app, ctx); if now < app.caches.denoise.next_pump { return; } @@ -85,8 +97,8 @@ pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { } /// The engine's model list, re-read at most every [`REFRESH_SECS`], or at once after an action changed it. -fn models(app: &mut LightcraftApp, ctx: &egui::Context) -> Value { - let now = ctx.input(|i| i.time); +fn models(app: &mut LightcraftApp, _ctx: &egui::Context) -> Value { + let now = app.tick_now(); let epoch = app.caches.denoise.epoch; if let Some((e, at, v)) = &app.caches.denoise.list && *e == epoch @@ -134,7 +146,7 @@ pub fn detail_toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, on let mut enabled = on || crate::model_setup::denoise_requested(app, id); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| { let r = ui.add_enabled(applicable, egui::Checkbox::new(&mut enabled, crate::i18n::tr("AI Denoise"))); - register(ui.ctx(), "denoise:enabled", r.rect); + register(ui, "denoise:enabled", r.rect); if r.changed() { let _ = app.run("denoise.toggle", json!({"id": id.0, "enabled": enabled})); } @@ -173,7 +185,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am note(ui, "AI Denoise needs a model. Install one in Settings.", t.text_dim); ui.add_space(2.0); let r = ui.small_button(crate::i18n::tr("Set up AI Denoise…")); - register(ui.ctx(), "denoise:setup", r.rect); + register(ui, "denoise:setup", r.rect); if r.clicked() { let _ = app.run("denoise.toggle", json!({"id": id.0, "enabled": true})); } @@ -182,7 +194,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am PhotoState::Idle => { if amount > 0.0 && app.session.denoise_auto() { // the pump has not met this photo yet: let it come round soon instead of waiting out its pause - let now = ui.ctx().input(|i| i.time); + let now = app.tick_now(); app.caches.denoise.next_pump = app.caches.denoise.next_pump.min(now + 0.05); ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { @@ -196,7 +208,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am note(ui, "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", t.text_dim); ui.add_space(2.0); let r = ui.small_button(crate::i18n::tr("Make it now")); - register(ui.ctx(), "denoise:make", r.rect); + register(ui, "denoise:make", r.rect); if r.clicked() { let _ = app.run("denoise.queue", json!({"ids": [id.0]})); } @@ -224,7 +236,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am note(ui, &sentence(&why), color); if !unsupported { let r = ui.small_button(crate::i18n::tr("Try again")); - register(ui.ctx(), "denoise:retry", r.rect); + register(ui, "denoise:retry", r.rect); if r.clicked() { let _ = app.run("denoise.queue", json!({"ids": [id.0], "retry": true})); } @@ -323,7 +335,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V hint(ui, t, &device_line(&status["device"], run_on)); if run_on != "cpu" && status["device"]["retry"] == true { let r = ui.small_button(crate::i18n::tr("Try the graphics card again")); - register(ui.ctx(), "denoise:retryGpu", r.rect); + register(ui, "denoise:retryGpu", r.rect); if r.clicked() { // choosing where it runs, even the same again, forgets the failed set-up let _ = app.run("denoise.settings", json!({"runOn": run_on})); @@ -361,13 +373,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } ui.horizontal(|ui| { let r = ui.button(tr("Denoise all photos with an Amount")); - register(ui.ctx(), "denoise:queueAll", r.rect); + register(ui, "denoise:queueAll", r.rect); if r.clicked() { let _ = app.run("denoise.queue", json!({"scope": "withAmount"})); } if running.is_some() || queued > 0 { let r = ui.button(tr("Stop")); - register(ui.ctx(), "denoise:stop", r.rect); + register(ui, "denoise:stop", r.rect); if r.clicked() { let _ = app.run("denoise.cancel", json!({})); } @@ -388,17 +400,18 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V ui.label(RichText::new(crate::i18n::tr("Limit")).color(t.text_label)); let current = list["cacheGb"].as_u64().unwrap_or(20) as u32; let mut choice = current; - crate::widgets::combo_box("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { + let combo = crate::widgets::combo_box("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { for gb in CACHE_LIMITS { ui.selectable_value(&mut choice, gb, tr_format!("{size} GB", size = gb)); } }); + crate::widgets::name_combo(&combo.response, tr("Cache limit")); if choice != current { let _ = app.run("denoise.settings", json!({"cacheGb": choice})); app.caches.denoise.epoch += 1; } let r = ui.button(tr("Clear cache")); - register(ui.ctx(), "denoise:clear", r.rect); + register(ui, "denoise:clear", r.rect); if r.clicked() { let _ = app.run("denoise.clear", json!({})); } @@ -482,21 +495,21 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if installed { let r = ui.button(tr("Remove")); - register(ui.ctx(), format!("denoise:remove:{id}"), r.rect); + register(ui, format!("denoise:remove:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.remove", json!({"id": id})); app.caches.denoise.epoch += 1; } if selected { let r = ui.button(tr("Turn off")); - register(ui.ctx(), format!("denoise:off:{id}"), r.rect); + register(ui, format!("denoise:off:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.select", json!({"id": null})); app.caches.denoise.epoch += 1; } } else { let r = ui.button(tr("Use")); - register(ui.ctx(), format!("denoise:use:{id}"), r.rect); + register(ui, format!("denoise:use:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.select", json!({"id": id})); app.caches.denoise.epoch += 1; @@ -506,7 +519,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match dl_state { Some("running") => { let r = ui.button(tr("Cancel")); - register(ui.ctx(), format!("denoise:cancelDownload:{id}"), r.rect); + register(ui, format!("denoise:cancelDownload:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.downloadCancel", json!({"id": id})); app.caches.denoise.dl_watch.retain(|w| w != &id); @@ -519,7 +532,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, let r = ui .button(tr("Open page")) .on_hover_text(tr("Opens the model's own page in your browser, to read about it or get the file yourself.")); - register(ui.ctx(), format!("denoise:get:{id}"), r.rect); + register(ui, format!("denoise:get:{id}"), r.rect); if r.clicked() { open_page(app, url); } @@ -532,7 +545,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, host = host ); let r = ui.button(tr(label)).on_hover_text(tip); - register(ui.ctx(), format!("denoise:download:{id}"), r.rect); + register(ui, format!("denoise:download:{id}"), r.rect); if r.clicked() { open_download_dialog(app, m); } @@ -595,8 +608,11 @@ fn count(n: u64, one: &str, many: &str) -> String { tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })) } +/// The pace to ask the denoise worker for: "light" when minimized, "normal" while unfocused, +/// "pause" shortly after input, then "light"/"full"/"normal" according to move recency and +/// whether the Denoise settings tab is being watched. pub(super) fn window_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64, watching: bool) -> &'static str { - let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false))); + let (focused, minimized) = ctx.input(|i| (i.raw.focused, i.viewport().minimized.unwrap_or(false))); pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching) } @@ -652,6 +668,9 @@ pub(super) fn open_page(app: &mut LightcraftApp, url: &str) { } } +/// Draw the denoise model's terms dialog from the engine's model info: unsupported files show +/// why; otherwise the name/size, download host, licence and provenance, an optional page link, +/// and the acceptance checkbox in `accepted`. pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info: &Value, accepted: &mut bool) { let kind = info["kind"].as_str().unwrap_or("unsupported"); let file = info["fileName"].as_str().unwrap_or(""); @@ -710,7 +729,7 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info } if let Some(url) = m["source"].as_str().or(m["licence"]["url"].as_str()) { let r = ui.link(tr("Open the model's page")); - register(ui.ctx(), "denoiseModel:page", r.rect); + register(ui, "denoiseModel:page", r.rect); if r.clicked() { open_page(app, url); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 444ef7e73..eee9d7d0c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -12,7 +12,44 @@ use crate::LightcraftApp; use crate::render::Slot; use crate::state::{BeforeAfter, RightPanel, ToolOverlay, Zoom}; use crate::theme::Tokens; -use crate::widgets::register; +use crate::widgets::{register, register_geometry}; + +mod crop; +mod filmstrip; +mod geometry; +mod info; +mod masks; +mod retouch; +mod window; + +pub(crate) use crop::crop_angle_label; +use crop::crop_overlay; +#[cfg(test)] +pub(crate) use crop::readout_rect; +#[cfg(test)] +use crop::{flipped_corners, outside_crop_mesh, rotate_cursor_turn}; +#[cfg(test)] +use filmstrip::film_label; +#[cfg(test)] +pub(crate) use filmstrip::film_spoken; +pub(crate) use filmstrip::filmstrip; +use filmstrip::navigator; +pub use geometry::CanvasMap; +#[cfg(test)] +pub(crate) use geometry::focus_box; +use geometry::{animated_rect, middle_pan}; +pub(crate) use geometry::{fit_rect, fit_texture_rect, focus_pan, navigate_gesture, output_px, pan_image, snap_fit_rect, texture_side}; +#[cfg(test)] +use info::RENDERING_AFTER_SECS; +use info::{ + FILTER_PILL_HEIGHT, FILTER_PILL_INSET, brief_info, filter_pill, filter_pill_shown, info_overlay, info_overlay_extent, label_badge_rect, + 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 retouch::{eye_overlay, remove_overlay}; +pub(crate) use window::overlay_supports_window; +use window::{WindowCtx, WindowView, draw_window, request_window}; /// An in-progress on-canvas gesture. #[derive(Clone, Debug)] @@ -85,400 +122,10 @@ pub enum Gesture { }, } -/// Screen ↔ normalized-image mapping for the displayed frame. -#[derive(Clone, Copy)] -pub struct CanvasMap { - rect: Rect, - to_norm: Affine, - from_norm: Affine, - k: f32, -} - -impl CanvasMap { - fn new(frame: &Frame, rect: Rect) -> CanvasMap { - let k = 8.0f32; - let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize); - let to_norm = frame.out_to_norm(w, h); - CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k } - } - pub fn norm(&self, p: Pos2) -> Point { - self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64)) - } - pub fn screen(&self, n: Point) -> Pos2 { - let q = self.from_norm.apply(n); - pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k) - } -} - -pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { - let zoom = match zoom { - Zoom::Percent(p) if !p.is_finite() || p <= 0.0 => Zoom::Fit, - _ => zoom, - }; - let (aw, ah) = (area.width(), area.height()); - let (w, h) = match zoom { - Zoom::Fit => { - if aw / ah > aspect { - (ah * aspect, ah) - } else { - (aw, aw / aspect) - } - } - Zoom::Fill => { - if aw / ah > aspect { - (aw, aw / aspect) - } else { - (ah * aspect, ah) - } - } - Zoom::Percent(p) => { - // the photo's own pixels at p% (its width as shown: cropped and rotated) - let w = img_px[0] as f32 * p / 100.0 / ppp; - (w, w / aspect) - } - }; - let pan = bounded_pan(area, vec2(w, h), pan); - let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h); - let rect = Rect::from_center_size(c, vec2(w, h)); - // At 100 % the texture maps texel for texel to screen pixels, so its corner must sit on a whole - // physical pixel: half a pixel off and the GPU's bilinear filter blurs the view (an odd-width - // canvas on a 2x display does this). Other zooms resample anyway, and a pinch must not jump. - let one_to_one = ppp.is_finite() && ppp > 0.0 && (w * ppp - img_px[0] as f32).abs() < 0.5; - if matches!(zoom, Zoom::Percent(_)) && one_to_one { - let snap = |v: f32| (v * ppp).round() / ppp; - return Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), rect.size()); - } - rect -} - -/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can -/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata. -pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { - let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); - let scale = (area.width() / w).min(area.height() / h); - Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) -} - -/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is -/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred -/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its -/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to -/// the sampler. -pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { - let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; - let (w, h) = (size[0] as f32, size[1] as f32); - if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { - return rect; - } - let snap = |v: f32| (v * ppp).round() / ppp; - Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) -} - -/// Keep the viewport inside the photo; axes smaller than the viewport stay centred. -fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { - let bound = |visible: f32, image: f32, centre: f32| { - let centre = if centre.is_finite() { centre } else { 0.5 }; - if image > visible.max(0.0) { - let half = visible.max(0.0) / image / 2.0; - centre.clamp(half, 1.0 - half) - } else { - 0.5 - } - }; - (bound(area.width(), size.x, pan.0), bound(area.height(), size.y, pan.1)) -} - -pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: egui::Vec2) { - let centre = area.center() - img.min - delta; - let pan = bounded_pan(area, img.size(), (centre.x / img.width().max(1.0), centre.y / img.height().max(1.0))); - let _ = app.run("view.navigate", json!({"pan": pan})); -} - -/// A drag with the middle button (the mouse wheel pressed) pans the zoomed photo whatever tool is -/// active, so the healing brush, mask brushes and crop never paint or move while you look around; -/// the hand cursor shows while it does. -fn middle_pan(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, area: Rect, img: Rect) -> bool { - if !resp.dragged_by(egui::PointerButton::Middle) { - return false; - } - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if img.width() > area.width() + 1.0 || img.height() > area.height() + 1.0 { - pan_image(app, area, img, resp.drag_delta()); - } - true -} - -/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. -fn wheel_notches(events: &[egui::Event]) -> f32 { - events - .iter() - .filter_map(|e| match e { - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. } - if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() => - { - Some(delta.y) - } - _ => None, - }) - .sum() -} - -/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view. -pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool { - if !resp.hovered() { - return false; - } - let (mut factor, mut delta, anchor) = - ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos()))); - // A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel - // delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan. - let wheel_id = egui::Id::new("loupe-wheel-zoom"); - if ui.input(|i| wheel_notches(&i.events)) != 0.0 { - ui.data_mut(|d| d.insert_temp(wheel_id, true)); - } - if ui.data(|d| d.get_temp::(wheel_id)).unwrap_or(false) { - let smooth = ui.input(|i| i.smooth_scroll_delta.y); - let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); - if smooth == 0.0 { - ui.data_mut(|d| d.remove_temp::(wheel_id)); - } else { - factor *= (speed * smooth).exp(); - delta = egui::Vec2::ZERO; - } - } - if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) { - return false; - } - let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false }; - let ppp = ui.ctx().pixels_per_point(); - let aspect = img.width() / img.height().max(1.0); - let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5)); - let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32; - let mut zoom = app.ui.zoom; - let size = if factor != 1.0 { - // Start at the displayed scale (including Fit/Fill and an interrupted click animation). - let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0); - zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) }; - fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size() - } else { - img.size() - }; - // The image point under the pointer remains there as its size changes, then follows the pan. - let point = (anchor - img.min) / img.size(); - let offset = area.center() - anchor - delta; - let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0))); - let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan})); - ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); - ui.ctx().request_repaint(); - true -} - -/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 until it does, the -/// smallest limit WebGL devices have). -/// -/// The value egui keeps, not the frame's raw input: a native host (egui-winit) reports the limit -/// in the first frame's input only, so the raw value is `None` from the second frame on -/// (issue #652: every window render was cut to 2048 px, with blurry strips beside it). -pub(crate) fn texture_side(ctx: &egui::Context) -> usize { - ctx.input(|i| i.max_texture_side) -} - -/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what -/// 100 % zoom and the render size limit are measured against), at least 1 × 1. -pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { - let (w, h) = frame.native_size(); - let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 }; - [px(w), px(h)] -} - -/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the -/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. -pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option { - let f = photo.meta.focus.as_ref()?; - let d = &photo.develop; - let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); - let [w, h] = output_px(&frame); - let to_out = frame.out_to_norm(w, h).inverse()?; - // stored on the upright photo: the user's Rotate / Flip on top, then the crop - let r = d.orientation.map_norm_rect(f.rect); - let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { - let q = to_out.apply(c); - (q.x / w as f64, q.y / h as f64) - }); - let xs = corners.map(|c| c.0); - let ys = corners.map(|c| c.1); - let out = lightcraft_geom::Rect { - x0: xs.iter().copied().fold(f64::INFINITY, f64::min), - y0: ys.iter().copied().fold(f64::INFINITY, f64::min), - x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { - return None; - } - // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) - let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); - ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) -} - -/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. -pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { - let r = focus_box(app.session.catalog.photo(id)?)?; - Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) -} - -/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner -/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. -/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:`). -pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { - let Some(r) = focus_box(photo) else { return }; - let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); - let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); - let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); - let arm = (b.width().min(b.height()) * 0.3).max(5.0); - let green = Color32::from_rgb(90, 230, 110); - for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { - let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; - p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); - p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); - } - let c = b.center(); - p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); - p.circle_filled(c, 1.5, green); - register(p.ctx(), key, b); -} - -/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. -fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { - let t = if *anim { 0.22 } else { 0.0 }; - let id = egui::Id::new("loupe_anim"); - let v = |k: &str, x: f32| ctx.animate_value_with_time(id.with(k), x, t); - let (c, s) = (target.center(), target.size()); - let r = Rect::from_center_size(pos2(v("cx", c.x), v("cy", c.y)), vec2(v("w", s.x), v("h", s.y))); - if *anim && (r.center() - c).abs().max_elem() < 0.5 && (r.size() - s).abs().max_elem() < 0.5 { - *anim = false; - } - r -} - -/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) -/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize -/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high -/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin -/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels. -pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { - use lightcraft_pipeline::Overlay as O; - match overlay { - O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, - } -} - -/// What a window render of the loupe needs to know about the view it belongs to. -struct WindowCtx { - id: PhotoId, - overlay: lightcraft_pipeline::Overlay, - /// Long edge of the frame the windows are cut from (`None`: no windows). - frame_edge: Option, - drawn_long: f32, - aspect: f32, - ppp: f32, - texture_side: usize, - crop_tool: bool, - interacting: bool, - /// Hash of the photo's develop settings. - look: u64, - /// A pinch or two-finger scroll is running: keep the windows there are. - holding: bool, -} - -/// One side of the loupe that gets a window render. -#[derive(Clone, Copy)] -struct WindowView { - slot: Slot, - /// The Before side (the photo without its edits). - before: bool, - /// The rect the photo is drawn in. - img: Rect, - /// The rect it is requested for (the drawn rect without a click-zoom animation). - target: Rect, - /// The area whose pixels are on screen. - visible: Rect, -} - -/// Ask for the window of `v` that holds what is on screen; remember it so its texture can be -/// drawn where it belongs. `None`: no window is wanted (or possible) for this view. -fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { - let frame_edge = c.frame_edge?; - // while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one - // per frame (zooming out would ask for ever wider ones) - if c.holding { - return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); - } - let (fw, fh) = - if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) }; - let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); - // what is on screen, in pixels of the window's frame (the drawn frame, scaled) - let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0); - let visible = ( - to_px * (v.visible.left() - v.target.left()), - to_px * (v.visible.top() - v.target.top()), - to_px * (v.visible.right() - v.target.left()), - to_px * (v.visible.bottom() - v.target.top()), - ); - let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; - // a window waits for the original another window job is decoding (see `defer_window`) - let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); - let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore); - if crate::region::defer_window(window_pending, original_held) { - return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); - } - let job = if v.before { - app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) - } else { - app.session.region_job(c.id, fw, fh, win, !c.crop_tool) - }; - // refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one - // render, so the whole frame is rendered at the drawn size instead (see `region::plan`) - let Some(job) = job else { - app.window_refused = Some((c.id, c.look, frame_edge)); - return None; - }; - // a drag renders drafts of the window, as it does of the whole frame. The Before tile stays - // plain; the After tile carries the same mask overlay as the whole-frame render. - let job = if c.interacting { job.draft() } else { job }; - let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; - let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; - // the After window is proofed like the whole-frame render under it; the Before side is - // never proofed (its whole-frame render isn't either) - let proof = app.ui.soft_proof.then_some(app.ui.proof); - let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof }); - // keyed as the renderer keys the texture (a display profile changes the key) - let job = app.renderer.job_for(v.slot, job); - // The overlay and the proof are part of the tile's identity too, so changing mask view/colour - // or the proof never leaves a stale region tile on screen while its replacement renders. - let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) }; - let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41); - let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; - // windows of other photos, looks and zooms of this side can't be drawn any more - app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); - app.region_tiles.insert((v.before, job.key), view); - app.renderer.request(v.slot, job, 99); - Some(view) -} - -/// Draw `v`'s window texture over the whole-frame render, if it is the one asked for (or, in a -/// drag, an earlier draft of it). -fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) { - let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return }; - let Some(tile) = app.region_tiles.get(&(v.before, tex.key)).filter(|t| t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) else { - return; - }; - let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); - let at = v.img.min + vec2(tile.window.x as f32 / fw * v.img.width(), tile.window.y as f32 / fh * v.img.height()); - let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * v.img.width(), tile.window.h as f32 / fh * v.img.height())); - p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); -} - +/// Draw the Develop loupe: the optional filmstrip, the fitted or zoomed photo (before/after and +/// full-screen aware), crop overlays and the render requests for the current view, or, when no +/// photo is active, the empty-library message if the library is empty and "No photo selected" +/// otherwise. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let full = ui.max_rect(); @@ -499,6 +146,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; + brief_info(app, ui.ctx(), id); let d = (*photo.develop).clone(); // the full-screen preview shows the photo only: no tool overlays let right = if fullscreen { RightPanel::None } else { app.ui.right }; @@ -536,19 +184,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { _ => vec![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(|t| t.photo == id) - .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(|t| t.photo == id)) - .or_else(|| app.renderer.thumb(id)) + .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); // 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. let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect); - let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); + let resp = ui.interact(canvas, egui::Id::unique("loupe"), Sense::click_and_drag()); crate::widgets::announce(&resp, crate::i18n::tr("Photo")); 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); @@ -824,9 +475,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); } - None => { + 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 => {} } } } @@ -860,7 +512,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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.ctx(), "canvas:beforeLabel", bg); + register(ui, "canvas:beforeLabel", bg); } // the line of a wipe over both if app.ui.before_after == BeforeAfter::Split { @@ -874,7 +526,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // 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.ctx(), "canvas:image", img_rect); + register(ui, "canvas:image", img_rect); let map = CanvasMap::new(&frame, img_rect); info_overlay(app, &p, canvas, &photo); if !fullscreen { @@ -963,7 +615,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && 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::new("loupe-rendering"), None), keys, coming, now)); + 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 @@ -978,9 +630,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let over = if over.is_positive() { over } else { canvas }; match failed { Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e), - None => { + // 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 => {} } } if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { @@ -999,513 +653,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the -/// photo's top left, inset 8 points, with the `text` size plus padding. Zoomed in, the photo -/// reaches past its `pane` (the canvas): the pill then sits at the pane's top left, so the label -/// stays in view (issue #2267: at 100 % it left the screen with the photo's corner), below the -/// `top_inset` the filter pill takes in that corner, and never past the pane's right edge. -fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) -> Rect { - let size = text + vec2(16.0, 8.0); - let left = photo.left().max(pane.left()).min(pane.right() - size.x - 16.0).max(pane.left()); - let corner = pos2(left, photo.top().max(pane.top() + top_inset)); - Rect::from_min_size(corner + vec2(8.0, 8.0), size) -} - -/// The info overlay at the canvas' top left (`view.infoOverlay`): the lines View Options chose for -/// Info 1 or Info 2 (`view.viewOptions`). -fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { - let Some(lines) = info_overlay_lines(app, photo) else { return }; - let t = Tokens::get(p.ctx()); - let mut y = canvas.top() + info_overlay_top(app, &t); - for (i, l) in lines.iter().enumerate() { - // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white - // layout made the shadow a second white copy (#1690) - let g = p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER); - let at = pos2(canvas.left() + 14.0, y); - // a soft shadow keeps the text readable on bright photos - p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); - p.galley(at, g.clone(), Color32::WHITE); - y += g.size().y + INFO_OVERLAY_GAP; - } - y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo); - register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); -} - -/// Where the info overlay's text starts below the canvas' top (without window controls over it), -/// and the gap after each line. -const INFO_OVERLAY_TOP: f32 = 12.0; -const INFO_OVERLAY_GAP: f32 = 3.0; - -/// The info overlay's font for line `i` (the file name is the first, larger line). -fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId { - if i == 0 { t.semibold(15.0) } else { t.font(12.5) } -} - -/// How far below the canvas' top the info overlay's text and badges reach (0 when it is off), so a -/// label drawn in that corner can sit below it. -fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 { - let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 }; - let t = Tokens::get(p.ctx()); - let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| { - y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP - }); - text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 } -} - -/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top -/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460). -fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { - let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen; - if controls { t.top_bar_h } else { INFO_OVERLAY_TOP } -} - -/// The info overlay's lines for `photo`, or `None` when it is off. -fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option> { - use crate::state::InfoOverlay; - let fields = match app.ui.info_overlay { - InfoOverlay::Off => return None, - InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, - InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, - }; - let lines: Vec = fields.iter().filter_map(|f| info_line(*f, photo)).collect(); - if lines.is_empty() { - return None; - } - Some(lines) -} - -/// The height of the info overlay's rating and flag row. -const OVERLAY_BADGES_H: f32 = 17.0; - -fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool { - photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None -} - -/// The star rating and flag under the info overlay's lines, when the photo has either; returns -/// the y below them. -fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 { - use crate::icons::{Icon, paint}; - use lightcraft_catalog::Flag; - let flag = match photo.flag { - Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)), - Flag::Reject => Some((Icon::FlagReject, t.reject)), - Flag::None => None, - }; - if !has_overlay_badges(photo) { - return at.y; - } - let stars = photo.rating.min(5); - let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize]; - icons.extend(flag); - let side = 12.0; - let mut x = at.x; - for (i, (icon, color)) in icons.into_iter().enumerate() { - // the flag stands a little apart from the stars - if i > 0 && i == stars as usize { - x += 5.0; - } - let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side)); - paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200)); - paint(p, r, icon, color); - x += side + 2.0; - } - register(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); - at.y + OVERLAY_BADGES_H -} - -/// What the user did to a face box this frame. -enum RegionEdit { - /// The × was clicked. - Remove(usize), - /// A handle drag ended: the region's new box (normalized, upright frame). - Resize(usize, lightcraft_geom::Rect), - /// A name was typed or picked for the face. - Name(usize, String), - /// The name box's offer to set up face recognition was pressed. - SetUp, -} - -/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show. -const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ - (0.0, 0.0, egui::CursorIcon::ResizeNwSe), - (0.5, 0.0, egui::CursorIcon::ResizeVertical), - (1.0, 0.0, egui::CursorIcon::ResizeNeSw), - (1.0, 0.5, egui::CursorIcon::ResizeHorizontal), - (1.0, 1.0, egui::CursorIcon::ResizeNwSe), - (0.5, 1.0, egui::CursorIcon::ResizeVertical), - (0.0, 1.0, egui::CursorIcon::ResizeNeSw), - (0.0, 0.5, egui::CursorIcon::ResizeHorizontal), -]; - -/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows -/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, -/// 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. -fn region_overlay( - ui: &egui::Ui, - p: &egui::Painter, - map: &CanvasMap, - photo: &lightcraft_catalog::Photo, - orient: lightcraft_geom::Orientation, - hints: Option<&super::faces::Hints>, - people: &[String], - editing: &mut Option, - setup: super::faces::Setup, -) -> Option { - let t = Tokens::get(p.ctx()); - let clip = p.clip_rect(); - let pointer = ui.input(|i| i.pointer.hover_pos()); - // the box being dragged: (region index, its box so far) - let drag_key = egui::Id::new("region-drag"); - let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key)); - let mut edit = None; - for (index, r) in photo.meta.regions.iter().enumerate() { - let dragging = live.filter(|(i, _)| *i == index); - let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); - let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); - // white with a black keyline just outside it, so the box shows on any background - p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); - p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside); - if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) { - for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { - let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); - let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Resize region")); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(*cursor); - } - let sq = Rect::from_center_size(c, vec2(7.0, 7.0)); - p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE }); - p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside); - if resp.dragged() - && let Some(pp) = resp.interact_pointer_pos() - { - // move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one - let mut n = rect; - if *fx == 0.0 { - n.min.x = pp.x.min(n.max.x - 12.0); - } else if *fx == 1.0 { - n.max.x = pp.x.max(n.min.x + 12.0); - } - if *fy == 0.0 { - n.min.y = pp.y.min(n.max.y - 12.0); - } else if *fy == 1.0 { - n.max.y = pp.y.max(n.min.y + 12.0); - } - let (a, b) = (map.norm(n.min), map.norm(n.max)); - let shown = lightcraft_geom::Rect { - x0: a.x.min(b.x).clamp(0.0, 1.0), - y0: a.y.min(b.y).clamp(0.0, 1.0), - x1: a.x.max(b.x).clamp(0.0, 1.0), - y1: a.y.max(b.y).clamp(0.0, 1.0), - }; - // back to the upright frame the region is stored in - let new = orient.inverse().map_norm_rect(shown); - ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); - ui.ctx().request_repaint(); - } - if resp.drag_stopped() { - if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) { - edit = Some(RegionEdit::Resize(i, new)); - } - ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key)); - } - } - } - if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { - let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); - let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); - register(ui.ctx(), format!("regionRemove:{index}"), xr); - let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); - p.rect_filled(xr, 2.0, fill); - p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); - let (c, m) = (xr.center(), 3.5); - let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) }); - p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); - p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); - if resp.on_hover_text(crate::i18n::tr("Remove this face box (undo with Edit ▸ Undo)")).clicked() { - edit = Some(RegionEdit::Remove(index)); - } - } - // what the label says: the name, a guess to confirm ("Jane Doe?"), or on hover an invitation to name the face - let is_face = r.kind == lightcraft_meta::RegionKind::Face; - let hint = hints.and_then(|h| h.by_index.get(&index)); - let hovered = pointer.is_some_and(|h| rect.expand(3.0).contains(h)); - let editing_this = editing.as_ref().is_some_and(|e| e.photo == photo.id.0 && e.index == index); - let (text, text_color, fill_alpha, invitation) = match (&r.name, hint.and_then(|h| h.suggestion.as_ref())) { - (Some(n), _) => (n.clone(), Color32::from_gray(225), 235, false), - (None, Some((n, _))) if is_face => (format!("{n}?"), Color32::from_gray(185), 200, false), - (None, None) if is_face && live.is_none() => ("Add name".to_string(), Color32::from_gray(150), 190, true), - _ => continue, - }; - // the name in a dark label with a caret, centred above the box (below it when there is no room) - let name = &text; - let g = p.layout_no_wrap(name.clone(), t.font(13.0), text_color); - let (pad, caret) = (vec2(14.0, 7.0), 5.0); - let size = g.size() + pad * 2.0; - let above = rect.top() - caret - size.y >= clip.top(); - let (top, tip, base) = if above { - (rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0) - } else { - (rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0) - }; - let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left())); - let label = Rect::from_min_size(pos2(left, top), size); - // the invitation shows while the pointer is on the box *or on the label* (and the gap between), so it can be reached - if invitation && !(hovered || editing_this || pointer.is_some_and(|h| rect.union(label).expand(3.0).contains(h))) { - continue; - } - let fill = Color32::from_rgba_unmultiplied(56, 56, 56, fill_alpha); - p.rect_filled(label, 3.0, fill); - p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside); - let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0); - p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE)); - p.galley(label.min + pad, g, text_color); - if is_face && live.is_none() { - // a click on the label opens the name box, filled with the name or the guess - let resp = ui.interact(label, egui::Id::new(("region-label", index)), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face")); - register(ui.ctx(), format!("regionLabel:{index}"), label); - if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() { - let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); - *editing = Some(crate::state::NameEdit { photo: photo.id.0, index, text: start, fresh: true }); - } - } - if editing_this && let Some(e) = editing.as_mut() { - let candidates = hint.map(|h| h.candidates.as_slice()).unwrap_or(&[]); - match super::faces::name_editor(ui.ctx(), pos2(label.left(), rect.bottom() + 8.0), e, candidates, people, setup) { - super::faces::Editor::Submit(name) => edit = Some(RegionEdit::Name(index, name)), - super::faces::Editor::Setup => edit = Some(RegionEdit::SetUp), - super::faces::Editor::Cancel => *editing = None, - super::faces::Editor::Open => {} - } - } - } - edit -} - -/// The filter pill's inset from the canvas' top-left corner and its height (the same inset from -/// the left as from the top, so the pill sits in the corner, #1645). -const FILTER_PILL_INSET: f32 = 3.0; -const FILTER_PILL_HEIGHT: f32 = 20.0; - -/// Whether [`filter_pill`] has something to show (a filter is active). -fn filter_pill_shown(app: &LightcraftApp) -> bool { - !lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty() -} - -/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous -/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the -/// canvas margin, so the photo does not move. -fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { - let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); - if chips.is_empty() { - return; - } - let t = Tokens::get(ui.ctx()); - let p = ui.painter_at(canvas); - let label = - chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::>().join(" · "); - let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225)); - let inset = FILTER_PILL_INSET; - let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0)); - let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, FILTER_PILL_HEIGHT)); - let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr("Filters")); - register(ui.ctx(), "loupe:filters", r); - p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); - let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); - p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); - let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0); - let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) }); - p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); - p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); - if resp.on_hover_text(crate::i18n::tr("Click to clear these filters")).clicked() { - let _ = app.run("library.clearFilter", json!({})); - } -} - -/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; -/// hovering it explains (registered as `notice:previewOnly:loupe`). -fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { - let t = Tokens::get(ui.ctx()); - let p = ui.painter_at(canvas); - let text = crate::i18n::tr_format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason)); - let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE); - let size = vec2(g.size().x + 40.0, 26.0); - let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size); - p.rect_filled(r, 13.0, Color32::from_black_alpha(185)); - crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(15.0, 15.0)), crate::icons::Icon::Info, t.caution); - p.galley(pos2(r.left() + 29.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); - register(ui.ctx(), "notice:previewOnly:loupe", r); - ui.interact(r, egui::Id::new("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason)); -} - -/// One line of the info overlay for `photo`; `None` when the field is None or the photo has -/// nothing to show for it. -fn info_line(field: crate::state::LoupeInfoField, photo: &lightcraft_catalog::Photo) -> Option { - use crate::state::LoupeInfoField as F; - let m = &photo.meta; - let text = match field { - F::None => return None, - F::FileName => photo.file_name.clone(), - F::CaptureDate => pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)), - F::Dimensions => format!("{} × {}", photo.width, photo.height), - F::Exposure => { - let mut exp = Vec::new(); - if !m.shutter.is_empty() { - exp.push(format!("{} s", m.shutter)); - } - if let Some(a) = m.aperture { - exp.push(format!("f/{a:.1}")); - } - if let Some(i) = m.iso { - exp.push(format!("ISO {i}")); - } - if let Some(f) = m.focal_mm { - exp.push(format!("{f:.0} mm")); - } - if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") } - } - F::Camera => m.camera.clone(), - F::Lens => m.lens.clone(), - F::CameraAndLens => [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "), - }; - (!text.is_empty()).then_some(text) -} - -/// `2026-09-30T12:00:00` → `2026-09-30 12:00`. -fn pretty_date(iso: &str) -> String { - let d = iso.replacen('T', " ", 1); - d.get(..16).map(str::to_string).unwrap_or(d) -} - -/// Size of the Navigator mini map (points). -const NAV_W: f32 = 180.0; - -/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the -/// visible region outlined; click or drag on it to pan. Returns the area it covers. -fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option { - let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; - if !app.ui.navigator || !zoomed || app.ui.fullscreen { - return None; - } - let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); - let aspect = img.width() / img.height().max(1.0); - let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) }; - let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h)); - let p = ui.painter_at(canvas); - p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); - match tex { - Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), - None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton), - }; - // the visible part of the photo, in normalized image coordinates - let vis = canvas.intersect(img); - let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height()); - let (a, b) = (n(vis.min), n(vis.max)); - let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height()); - let view = Rect::from_min_max(to_nav(a), to_nav(b)); - p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); - p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); - register(ui.ctx(), "canvas:navigator", frame); - let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Navigator")); - if (resp.clicked() || resp.dragged()) - && let Some(q) = resp.interact_pointer_pos() - { - // centre the view on the point under the pointer - let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0); - let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); - app.ui.pan = (u, v); - } - Some(frame.expand(5.0)) -} - /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. const PREFETCH_PRIORITY: u32 = 4; -/// How long a render may be on its way, with nothing new on screen, before the loupe says -/// "Rendering…" (s): as long as the activity stack waits before it shows a task. -const RENDERING_AFTER_SECS: f64 = 0.5; - -/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on -/// screen, since when, and whether the pill is up. -type RenderingWait = Option<([Option; 4], f64, bool)>; - -/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with -/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws -/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming: -/// drafts slower than the wait don't make it blink. -pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { - if !coming { - *wait = None; - return false; - } - let (seen, since, up) = wait.get_or_insert((keys, now, false)); - if !*up && *seen != keys { - (*seen, *since) = (keys, now); - } - *up |= now - *since > RENDERING_AFTER_SECS; - *up -} - -/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that -/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The -/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see -/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all -/// there will be. -pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { - let t = Tokens::get(ui.ctx()); - let p = ui.painter_at(canvas); - let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225)); - let inset = if caution { 29.0 } else { 10.0 }; - let size = vec2(g.size().x + inset + 10.0, 20.0); - let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size); - if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) { - r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size); - } - p.rect_filled(r, 10.0, Color32::from_black_alpha(185)); - if caution { - crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution); - } - p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); - register(ui.ctx(), id, r); - r -} - -/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]): -/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with -/// the error on hover and for screen readers. -#[allow(clippy::too_many_arguments)] -pub(crate) fn failed_pill( - ui: &egui::Ui, - app: &LightcraftApp, - photo: &lightcraft_catalog::Photo, - canvas: Rect, - over: Rect, - navigator: Option, - id: &str, - error: &str, -) { - let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path)); - let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" }); - let r = loupe_pill(ui, canvas, over, navigator, id, text, true); - let resp = ui.interact(r, egui::Id::new(("render-failed", id)), Sense::hover()).on_hover_text(error); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, format!("{text}: {error}"))); -} - -fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { - use lightcraft_engine::media::QuickSource; - match q { - QuickSource::Cached => "cached", - QuickSource::Embedded => "embedded", - QuickSource::Small => "small", - } -} - /// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or /// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all /// in one interaction = one undo step). @@ -1671,1521 +821,6 @@ fn general_interaction( let _ = (native, aspect); } -// ------------------------------------------------------------------------ crop - -fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) { - // the selected guide belongs to this photo and to the Guided Upright tool, and to a guide - // that still exists (a Delete after the last one leaves the selection behind) - if app.ui.tool != "guidedUpright" || app.ui.selected_guide.is_some_and(|(photo, i)| photo != id.0 || i >= d.geometry.guides.len()) { - app.ui.selected_guide = None; - } - if app.ui.tool == "guidedUpright" { - guided_overlay(app, ui, resp, map, frame, d, id); - return; - } - if app.ui.tool == "straighten" { - straighten_overlay(app, ui, resp, false); - return; - } - // hold ⌘ and drag: draw a straighten line without leaving the crop tool - let cmd_drag = resp.drag_started() && ui.input(|i| i.modifiers.command); - if cmd_drag || matches!(app.gesture, Some(Gesture::StraightenLine { .. })) { - straighten_overlay(app, ui, resp, true); - return; - } - if resp.hovered() && ui.input(|i| i.modifiers.command) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - let quad = frame_crop_quad(d, frame); - let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); - let p = ui.painter(); - // darken outside the crop (the box's corners in screen order: a flipped photo shows its - // top-left corner elsewhere) - let quad = flipped_corners(&pts, frame.flip_h, frame.flip_v); - if pts.len() == 4 { - p.add(outside_crop_mesh(map.rect, &quad, Color32::from_black_alpha(150))); - } - p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE))); - // overlay guides - let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t; - let guide = Stroke::new(0.8, Color32::from_white_alpha(150)); - // (u, v) across / down the (rotated) crop frame - let at = |(u, v): (f32, f32)| pts[0] + (pts[1] - pts[0]) * u + (pts[3] - pts[0]) * v; - let (cw, ch) = (pts[0].distance(pts[1]), pts[0].distance(pts[3])); - for line in super::crop_overlay::paths(app.ui.crop_overlay, app.ui.crop_overlay_orient, cw, ch) { - p.add(egui::Shape::line(line.into_iter().map(at).collect(), guide)); - } - // handles: 0..3 corners, 4..7 edges (top, right, bottom, left) - let handles: Vec = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect(); - for (i, h) in handles.iter().enumerate() { - register(ui.ctx(), format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0))); - let s = if i < 4 { 12.0 } else { 9.0 }; - p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE); - p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE); - } - // the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move - if crop_keys(app, ui, map, &handles, &pts, d, frame) { - return; - } - let inside = |q: Pos2| { - let n = map.norm(q); - let s = to_straight(n, d.crop.geometry.angle, frame); - d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y)) - }; - // a crop drag cut short (the tool closed before the release) leaves its gesture behind: with no - // button held it is over - if matches!(app.gesture, Some(Gesture::CropRotate { .. } | Gesture::CropHandle { .. })) - && !resp.drag_stopped() - && !ui.input(|i| i.pointer.any_down()) - { - app.gesture = None; - } - // while rotating, the pointer may leave the canvas: it still shows rotation and the angle - let rotating = matches!(app.gesture, Some(Gesture::CropRotate { .. })); - let pointer = resp.hover_pos().or_else(|| if rotating { ui.input(|i| i.pointer.latest_pos()) } else { None }); - // with ⌘ held a drag draws a level line (the straighten crosshair, set above), not a rotation - let straightening = !rotating && ui.input(|i| i.modifiers.command); - if let Some(hq) = pointer.filter(|_| !straightening) { - // a move or resize drag keeps its pointer wherever it goes, even past the box - let held = match app.gesture { - Some(Gesture::CropHandle { handle, .. }) => Some(usize::from(handle)), - _ => None, - }; - let near = held.or_else(|| handles.iter().position(|h| h.distance(hq) < 12.0)).filter(|_| !rotating); - let cursor = match near { - Some(0 | 2) => egui::CursorIcon::ResizeNwSe, - Some(1 | 3) => egui::CursorIcon::ResizeNeSw, - Some(4 | 6) => egui::CursorIcon::ResizeVertical, - Some(5 | 7) => egui::CursorIcon::ResizeHorizontal, - // 8: the whole box - Some(_) => egui::CursorIcon::Move, - None if inside(hq) && !rotating => egui::CursorIcon::Move, - // a drag here rotates: no system cursor shows that, so draw a curved double arrow - None => { - rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq))); - egui::CursorIcon::None - } - }; - ui.ctx().set_cursor_icon(cursor); - } - // the angle next to the pointer, drawn once this frame's rotation is applied (below) - let readout_at = pointer.filter(|_| rotating); - let mut shown_angle = d.crop.geometry.angle; - // double-click inside the crop box applies the crop (same as Return / Done) - if resp.double_clicked() - && let Some(q) = resp.interact_pointer_pos() - && inside(q) - && !handles.iter().any(|h| h.distance(q) < 12.0) - { - let _ = app.run("tool.done", json!({})); - return; - } - // egui reports a drag only once the pointer has moved a few px: the zone is where the button - // went down, where the cursor showed it, not where the pointer is by then - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) - { - if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) - { - app.ui.status = e; - let _ = app.session.cancel_interaction(); - return; - } - let d = app.session.develop_of(id).unwrap_or_default(); - app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) { - Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, - None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, - None => { - let c = map.screen(Point::new(0.5, 0.5)); - Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() } - } - }); - } - if resp.dragged() - && let Some(q) = resp.interact_pointer_pos() - { - match app.gesture.clone() { - Some(Gesture::CropHandle { handle, start, angle }) => { - // The crop model decides what the drag does (anchor, aspect lock, image bounds): see - // `lightcraft_geom::drag_crop`; the UI only reports where the pointer started and is. - let n = to_straight(map.norm(q), angle, frame); - let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n); - // Shift keeps the current proportions for this drag only; the Lock toggle is untouched. - let keep_ratio = ui.input(|i| i.modifiers.shift); - let _ = app.run( - "crop.drag", - json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1], "keepRatio": keep_ratio}), - ); - } - Some(Gesture::CropRotate { start_angle, a0 }) => { - let c = map.screen(Point::new(0.5, 0.5)); - let a = (q - c).angle(); - let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0); - shown_angle = (ang * 100.0).round() / 100.0; - let _ = app.run("crop.straighten", json!({"angle": shown_angle})); - } - _ => {} - } - } - if resp.drag_stopped() { - app.gesture = None; - let _ = app.run("develop.endInteraction", json!({})); - } - if let Some(at) = readout_at { - // the canvas (the loupe's own rect), not the photo: rotating happens in the margin around it - angle_readout(ui, at, shown_angle, resp.rect); - } -} - -/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 -/// edges (top, right, bottom, left), 8 the whole box. -const CROP_HANDLE_NAMES: [&str; 9] = [ - "Crop top-left corner", - "Crop top-right corner", - "Crop bottom-right corner", - "Crop bottom-left corner", - "Crop top edge", - "Crop right edge", - "Crop bottom edge", - "Crop left edge", - "Crop box", -]; - -/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one -/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the -/// frame is over (the crop was applied). -fn crop_keys( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - map: &CanvasMap, - handles: &[Pos2], - pts: &[Pos2], - d: &DevelopSettings, - frame: &Frame, -) -> bool { - let r = d.crop.geometry.rect; - let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false }; - let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; - let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1)); - for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() { - let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue }; - let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at); - if keys.activated { - let _ = app.run("tool.done", json!({})); - return true; - } - let Some((dx, dy)) = keys.delta else { continue }; - // the drag runs in the straightened crop frame, as a pointer drag from the handle would; - // the arrows follow the screen - let angle = d.crop.geometry.angle; - let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height()); - let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame)); - let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)); - if let Err(e) = started { - app.ui.status = e; - let _ = app.session.cancel_interaction(); - return false; - } - let _ = app.run( - "crop.drag", - json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}), - ); - let _ = app.run("develop.endInteraction", json!({})); - } - false -} - -/// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. -pub(crate) fn crop_angle_label(angle: f64) -> String { - let shown = - lightcraft_develop::controls::find("crop.angle").map_or_else(|| format!("{angle:.2}"), |spec| crate::widgets::shown_value(spec, angle)); - // the slider's format turns a rounded −0.00 into "0": every zero reads "0.00", the angle's - // usual two decimals - let shown = if shown == "0" { "0.00".to_string() } else { shown }; - format!("{shown}°") -} - -/// Where the angle readout of `size` goes for a pointer `at`: below right of it, flipped to the -/// left / above where that would leave `bounds`, then kept inside them. -pub(crate) fn readout_rect(at: Pos2, size: egui::Vec2, bounds: Rect) -> Rect { - // without a real pointer or bounds (NaN, an empty rect) there is nothing to keep it in; and - // f32::clamp panics on NaN, so only finite numbers get there - if !at.is_finite() || !bounds.is_finite() || !bounds.is_positive() { - let at = if at.is_finite() { at } else { bounds.min.max(Pos2::ZERO) }; - let at = if at.is_finite() { at } else { Pos2::ZERO }; - return Rect::from_min_size(at + vec2(18.0, 14.0), size); - } - let x = if at.x + 18.0 + size.x > bounds.right() { at.x - 18.0 - size.x } else { at.x + 18.0 }; - let y = if at.y + 14.0 + size.y > bounds.bottom() { at.y - 14.0 - size.y } else { at.y + 14.0 }; - let x = x.clamp(bounds.left(), (bounds.right() - size.x).max(bounds.left())); - let y = y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top())); - Rect::from_min_size(pos2(x, y), size) -} - -/// On top of everything (the tooltip layer), so neither the photo nor a panel covers it. -fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter { - ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new(name))) -} - -/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo, -/// turned `turn` radians clockwise ([`rotate_cursor_turn`]). -fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) { - let p = top_painter(ui, "crop-rotate-cursor"); - let r = Rect::from_center_size(at, vec2(22.0, 22.0)); - p.circle_filled(at, 12.0, Color32::from_black_alpha(150)); - crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn); - register(ui.ctx(), "cropRotateCursor", r); -} - -/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges -/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge, -/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest -/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it -/// the other way, still away from that edge. -fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 { - let nearest = (0..pts.len()) - .map(|i| { - let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); - let ab = b - a; - let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; - a + ab * t - }) - .min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q))); - let Some(nearest) = nearest else { return 0.0 }; - let away = if inside { nearest - q } else { q - nearest }; - if away.length() < 0.5 { - return 0.0; - } - away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2 -} - -/// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the -/// pointer's other side when it would run past an edge. -fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { - let t = Tokens::get(ui.ctx()); - let p = top_painter(ui, "crop-angle-readout"); - let galley = p.layout_no_wrap(crop_angle_label(angle), t.font(12.5), Color32::WHITE); - let rect = readout_rect(at, galley.size() + vec2(12.0, 6.0), bounds); - p.rect_filled(rect, 4.0, Color32::from_black_alpha(170)); - p.galley(rect.min + vec2(6.0, 3.0), galley, Color32::WHITE); - register(ui.ctx(), "cropAngleReadout", rect); -} - -/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are -/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps. -fn guided_overlay( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - resp: &egui::Response, - map: &CanvasMap, - frame: &Frame, - d: &DevelopSettings, - id: PhotoId, -) { - let p = ui.painter_at(map.rect.expand(8.0)); - let col = Color32::from_rgb(255, 196, 40); - // the selected guide: brighter, so the Delete key has a visible target - let selected = app.ui.selected_guide.filter(|(photo, _)| *photo == id.0).map(|(_, i)| i); - let draw = |a: Pos2, b: Pos2, sel: bool| { - let (edge, core, at) = if sel { (3.5, 2.5, 5.0) } else { (3.0, 1.5, 4.0) }; - p.line_segment([a, b], Stroke::new(edge, Color32::from_black_alpha(120))); - p.line_segment([a, b], Stroke::new(core, if sel { Color32::WHITE } else { col })); - for q in [a, b] { - p.circle_filled(q, at, if sel { Color32::WHITE } else { col }); - p.circle_stroke(q, at, Stroke::new(1.0, Color32::BLACK)); - } - }; - let on_screen = |guide: &(Point, Point)| { - let (sa, sb) = (map.screen(frame.corrected_to_transformed(guide.0)), map.screen(frame.corrected_to_transformed(guide.1))); - (sa, sb) - }; - for (i, guide) in d.geometry.guides.iter().enumerate() { - let (sa, sb) = on_screen(guide); - register(ui.ctx(), format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb)); - draw(sa, sb, selected == Some(i)); - } - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - // A press starts what it means, right away (the pointer's press point, not where a drag - // threshold would have been crossed): on an endpoint it drags that endpoint (the guide is - // selected when it lands), on a line it selects the guide (a press on the selected one - // deselects), anywhere else it starts a new guide from the press point. The primary button - // only, never on the navigator — it pans the photo, it draws no guide — and only where the - // loupe itself takes the press: `contains_pointer` is layer-aware over the current pointer - // (the raw pointer is not), and `layer_id_at` asks the same question at the press point — - // a menu can receive the press and the pointer can leave it within the same frame, so the - // press itself must belong to the loupe's layer. Anything floating above the photo — a - // context menu, a dropdown, a dialog — owns its own press; the guide under it stays put. - // A slow frame can also swallow the whole press-move-release into one batch, and egui - // clears `press_origin` on the release: then the press position is read back from the - // frame's own events, so the gesture still starts where the pointer went down. - let nav = app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r); - if ui.input(|i| i.pointer.primary_pressed()) - && let Some(q) = ui - .input(|i| { - i.pointer.press_origin().or_else(|| { - i.events.iter().rev().find_map(|e| match e { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos), - _ => None, - }) - }) - }) - .or(resp.interact_pointer_pos()) - && resp.contains_pointer() - && ui.ctx().layer_id_at(q) == Some(resp.layer_id) - && resp.rect.contains(q) - && !nav.is_some_and(|n| n.expand(5.0).contains(q)) - { - let endpoint = d.geometry.guides.iter().enumerate().find_map(|(i, guide)| { - let (a, b) = on_screen(guide); - if a.distance(q) <= 7.0 { - Some((i, 0)) - } else if b.distance(q) <= 7.0 { - Some((i, 1)) - } else { - None - } - }); - let line = d.geometry.guides.iter().position(|guide| { - let (a, b) = on_screen(guide); - point_segment_dist(q, a, b) <= 6.0 - }); - match (endpoint, line) { - (Some((i, end)), _) => app.gesture = Some(Gesture::GuideEndpoint { i, end, from: q }), - (None, Some(i)) => app.ui.selected_guide = if selected == Some(i) { None } else { Some((id.0, i)) }, - // drawing a new guide leaves nothing selected - (None, None) => { - app.ui.selected_guide = None; - app.gesture = Some(Gesture::Guide { a: map.norm(q) }); - } - } - } - let q = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); - if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, q) { - draw(map.screen(*a), q, false); - } - // the endpoint being dragged: the guide follows the pointer, bright - if let (Some(Gesture::GuideEndpoint { i, end, .. }), Some(q)) = (&app.gesture, q) - && let Some(guide) = d.geometry.guides.get(*i) - { - let fixed = if *end == 0 { guide.1 } else { guide.0 }; - draw(map.screen(frame.corrected_to_transformed(fixed)), q, true); - } - if ui.input(|i| i.pointer.any_released()) { - match app.gesture.take() { - // the endpoint lands: the guide stays (one undo step) and is selected. A press - // without a real drag only selects — no no-op undo entry for a click. - Some(Gesture::GuideEndpoint { i, end, from }) => { - app.ui.selected_guide = Some((id.0, i)); - if let (Some(q), Some(guide)) = (q, d.geometry.guides.get(i)) { - let fixed_t = if end == 0 { guide.1 } else { guide.0 }; - let new_t = frame.transformed_to_corrected(map.norm(q)); - let (a, b) = if end == 0 { (new_t, fixed_t) } else { (fixed_t, new_t) }; - let (na, nb) = (frame.corrected_to_transformed(a), frame.corrected_to_transformed(b)); - // a press that only selected (it landed within the endpoint's grab radius - // and never moved) writes no guide: compare against where it went down - let moved = from.distance(q) > 2.0; - if moved && na.dist(nb) > 0.02 { - let all: Vec<[f64; 4]> = d - .geometry - .guides - .iter() - .enumerate() - .map(|(j, g)| { - let (x, y) = if j == i { (a, b) } else { *g }; - [x.x, x.y, y.x, y.y] - }) - .collect(); - let _ = app.run("geometry.guides", json!({"guides": all})); - } - } - } - Some(Gesture::Guide { a }) => { - if let Some(q) = q - && map.norm(q).dist(a) > 0.02 - { - let b = map.norm(q); - let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b)); - let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true})); - } - } - other => app.gesture = other, - } - } -} - -/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in. -fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { - let (w, h) = (frame.ow, frame.oh); - let px = Point::new(n.x * w, n.y * h); - let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px); - Point::new(r.x / w, r.y / h) -} - -/// How far `q` is from the segment `a`–`b` (screen pixels): the click that selects a guide. -fn point_segment_dist(q: Pos2, a: Pos2, b: Pos2) -> f32 { - let ab = b - a; - let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; - (a + ab * t).distance(q) -} - -/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) -/// in the order they appear on screen when the photo is shown flipped. -fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { - let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO); - let order = match (flip_h, flip_v) { - (false, false) => [0, 1, 2, 3], - (true, false) => [1, 0, 3, 2], - (false, true) => [3, 2, 1, 0], - (true, true) => [2, 3, 0, 1], - }; - order.map(at) -} - -/// The dimming outside the crop box: the photo's rectangle minus the crop quad, as one mesh of -/// non-overlapping convex pieces. The quad is a rotated rectangle, so its opposite sides are -/// parallel and the plane outside it splits into four disjoint half-strips: outside edge `i` -/// and inside edge `i + 1`. Each is clipped to the photo and fanned into triangles. -/// -/// Issue #731: joining photo corner `i` to crop corner `i` with a quad per side only works while -/// the two rectangles are aligned. Rotated, those quads cross themselves and each other, which -/// showed as dimmed wedges inside the crop and doubly dark ones outside, growing with the angle. -/// A degenerate quad (a point, a line, a non-finite corner) dims nothing. -fn outside_crop_mesh(img: Rect, quad: &[Pos2; 4], color: Color32) -> egui::epaint::Mesh { - let mut mesh = egui::epaint::Mesh::default(); - if !quad.iter().all(|q| q.x.is_finite() && q.y.is_finite()) || !img.is_finite() { - return mesh; - } - let centre = pos2((quad[0].x + quad[1].x + quad[2].x + quad[3].x) / 4.0, (quad[0].y + quad[1].y + quad[2].y + quad[3].y) / 4.0); - // signed distance from edge i's line, positive towards the crop's centre - let side = |i: usize, q: Pos2| -> Option { - let (a, b) = (quad[i % 4], quad[(i + 1) % 4]); - let n = vec2(a.y - b.y, b.x - a.x); - let orient = n.dot(centre - a); - // an edge the centre lies on: the box is flat - (orient.abs() > 1e-6 * n.length().max(1.0)).then(|| n.dot(q - a) * orient.signum()) - }; - // Sutherland–Hodgman: the part of a convex polygon where `keep` is at or above zero - let clip = |poly: &[Pos2], keep: &dyn Fn(Pos2) -> Option| -> Option> { - let mut out = Vec::with_capacity(poly.len() + 2); - for (k, &a) in poly.iter().enumerate() { - let b = *poly.get((k + 1) % poly.len())?; - let (da, db) = (keep(a)?, keep(b)?); - if da >= 0.0 { - out.push(a); - } - if (da < 0.0) != (db < 0.0) && (da - db).abs() > f32::EPSILON { - let t = da / (da - db); - out.push(a + (b - a) * t); - } - } - Some(out) - }; - let photo = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; - for i in 0..4 { - let outside = |q: Pos2| side(i, q).map(|d| -d); - let inside_next = |q: Pos2| side(i + 1, q); - let Some(strip) = clip(&photo, &outside).and_then(|poly| clip(&poly, &inside_next)) else { return egui::epaint::Mesh::default() }; - if strip.len() < 3 { - continue; - } - let base = mesh.vertices.len() as u32; - mesh.vertices.extend(strip.iter().map(|&pos| egui::epaint::Vertex { pos, uv: Pos2::ZERO, color })); - for k in 1..(strip.len() as u32).saturating_sub(1) { - mesh.indices.extend([base, base + k, base + k + 1]); - } - } - mesh -} - -fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { - let f = Frame { crop: d.crop.geometry, ..frame.clone() }; - f.crop_quad_norm() -} - -// ------------------------------------------------------------------------ masks - -/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. -pub(crate) fn component_pin(shape: &MaskShape) -> Option { - match shape { - MaskShape::Radial { center, .. } => Some(*center), - MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), - MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), - _ => None, - } -} - -/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. -fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { - let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); - match shape.clone() { - MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle { - 1 | 3 => { - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let (s, co) = angle.to_radians().sin_cos(); - let new_rx = (dx * co + dy * s).abs().max(0.01); - let mut new_ry = ry; - if alt { - new_ry = ry * (new_rx / rx.max(0.01)); - } - if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { - new_ry = new_rx; - } - MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } - } - 2 | 4 => { - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let (s, co) = angle.to_radians().sin_cos(); - let new_ry = (dx * (-s) + dy * co).abs().max(0.01); - let mut new_rx = rx; - if alt { - new_rx = rx * (new_ry / ry.max(0.01)); - } - if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { - new_rx = new_ry; - } - MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } - } - 5 => { - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let new_angle = dy.atan2(dx).to_degrees(); - MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert } - } - _ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, - }, - MaskShape::Linear { start, end } => match handle { - 1 => MaskShape::Linear { start: at, end }, - 2 => MaskShape::Linear { start, end: at }, - _ => MaskShape::Linear { start: mv(start), end: mv(end) }, - }, - MaskShape::Brush { mut strokes } => { - for st in &mut strokes { - st.points.iter_mut().for_each(|p| *p = mv(*p)); - } - MaskShape::Brush { strokes } - } - s => s, - } -} - -/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units, -/// so undo the same scaling and rotation used to draw its outline before testing containment. -fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { - let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false }; - if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 { - return false; - } - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let (s, co) = angle.to_radians().sin_cos(); - let x = (dx * co + dy * s) / rx; - let y = (-dx * s + dy * co) / ry; - x * x + y * y <= 1.0 -} - -/// Colour Range eyedropper: a click samples the colour under it for the selected mask's -/// colour range, at normalized frame coordinates (crop, rotation and zoom aware); -/// ⇧-click adds a sample (up to 5). -fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) { - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let add = ui.input(|i| i.modifiers.shift); - if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) { - app.toast(ui.ctx(), e); - } - } -} - -/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask -/// component (click selects its mask, drag moves the component), and brush painting. The mask -/// itself shows as a rendered overlay ([`view_overlay`]). -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); - // (mask, component, handle, screen position) of everything that can be grabbed - let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); - // pins follow Show Pins and the Tool Overlay mode (Shift+H); Auto hides them while brushing - let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); - let mut pins = p.clone(); - if mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Brush { .. })) { - pins.set_invisible(); - } - for m in &d.masks { - let sel = Some(m.id) == active; - // Ctrl+Shift+O (`mask_overlay_all`) shows every visible mask's region; outline each one - // too, not just the selected mask. Handles and the rotation grip stay on the selected - // 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.ctx(), 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.ctx(), 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.ctx(), 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 app.ui.mask_pins - && mode.shows(sel, live) - && let Some(at) = component_pin(&c.shape) - { - let q = map.screen(at); - pin(&pins, q, sel); - register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 0, q)); - } - } - } - 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) - 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() { - if let MaskShape::Object { hint, exclude, .. } = &c.shape { - let (hint, exclude) = match pending.as_ref().filter(|q| (q.mask, q.comp) == (m.id, k)) { - Some(q) => (&q.hint, &q.exclude), - None => (hint, exclude), - }; - for (pts, col) in [(hint, Color32::from_rgb(40, 200, 90)), (exclude, Color32::from_rgb(230, 60, 60))] { - for q in pts { - let q = map.screen(*q); - p.circle_filled(q, 5.0, col); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - } - } - } - } - } - // 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 - 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((ui.input(|i| i.time) + 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 }); - } - } - } - // 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; - } - // the grip under the pointer: the selected mask's first, then the nearest - let hit = |q: Pos2| { - grips - .iter() - .filter(|g| g.3.distance(q) < 10.0) - .min_by(|a, b| { - (Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal) - }) - .copied() - }; - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - && let Some((mid, ..)) = hit(q) - && Some(mid) != active - { - let _ = app.run("mask.select", json!({"id": mid})); - } - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { - let m = d.masks.iter().find(|m| Some(m.id) == active)?; - // Handles take precedence; otherwise grab the selected radial component under - // the press, including components after the first in compound masks. - let at = map.norm(q); - if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) { - return Some((m.id, ci, 0)); - } - // anywhere on the photo drags the selected linear gradient - matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) - }); - if let Some((mask, comp, handle)) = grip - && let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()) - { - if Some(mask) != active { - let _ = app.run("mask.select", json!({"id": mask})); - } - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape }); - } - } - if resp.dragged() - && let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) - { - let n = map.norm(q); - let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q); - let n0 = map.norm(press_origin); - let dn = Point::new(n.x - n0.x, n.y - n0.y); - let alt = ui.input(|i| i.modifiers.alt); - let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt); - let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); - } - if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { - app.gesture = None; - let _ = app.run("develop.endInteraction", json!({})); - } -} - -/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial -/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end. -fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String { - let part = match (shape, handle) { - (_, 0) => "pin", - (MaskShape::Radial { .. }, 1) => "right edge", - (MaskShape::Radial { .. }, 2) => "bottom edge", - (MaskShape::Radial { .. }, 3) => "left edge", - (MaskShape::Radial { .. }, 4) => "top edge", - (MaskShape::Radial { .. }, _) => "rotation", - (MaskShape::Linear { .. }, 1) => "start", - (MaskShape::Linear { .. }, _) => "end", - _ => "handle", - }; - format!("{mask} {}", crate::i18n::tr(part)) -} - -/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a -/// step (one undo step), as dragging it would; Return on a pin selects its mask. -fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) { - for &(mid, ci, handle, q) in grips { - let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue }; - let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue }; - let at = map.norm(q); - let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() }; - let name = mask_handle_name(&mask_name, shape, handle); - let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q); - if keys.activated && app.session.active_mask != Some(mid) { - let _ = app.run("mask.select", json!({"id": mid})); - } - let Some((dx, dy)) = keys.delta else { continue }; - if app.session.active_mask != Some(mid) { - let _ = app.run("mask.select", json!({"id": mid})); - } - let to = Point::new(at.x + dx, at.y + dy); - let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false); - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape})); - let _ = app.run("develop.endInteraction", json!({})); - } -} - -/// Long-edge units → normalized units for x and y. -fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { - // derive from the mapping: 1 normalized unit in x and y in screen points - let o = map.screen(Point::new(0.0, 0.0)); - let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64; - let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64; - let l = x.max(y); - (l / x.max(1e-9), l / y.max(1e-9)) -} - -/// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is -/// being dragged. -fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool { - resp.hover_pos().is_some_and(|q| map.rect.contains(q)) - || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) - // a handle moved with the keyboard stays shown while it has the focus - || crate::canvas_keys::has_focus(&resp.ctx) -} - -fn pin(p: &egui::Painter, c: Pos2, sel: bool) { - p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) }); - p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE)); -} - -// ------------------------------------------------------------------------ remove - -/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), -/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. -/// The Tool Overlay mode (Shift+H) says which spots show; a hidden spot can't be grabbed, so a -/// click there adds a new one. -fn remove_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_spot.filter(|i| *i < d.spots.len()); - let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); - // Auto also hides them while a new spot is painted - let painting = mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Spot { .. })); - let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); - let mut pins = p.clone(); - if painting { - pins.set_invisible(); - } - // (spot, is source, screen centre, radius) of everything that can be grabbed - let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); - for (i, s) in d.spots.iter().enumerate() { - let r = (s.size * long) as f32; - let sel = Some(i) == active; - if !mode.shows(sel, live) { - continue; - } - let p = &pins; - let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); - for q in &s.points { - p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); - } - let Some(&t) = s.points.first() else { continue }; - let tq = map.screen(t); - if let Some(o) = s.source_offset.filter(|_| sel) { - let sq = map.screen(Point::new(t.x + o.x, t.y + o.y)); - for q in &s.points { - p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170))); - } - let dir = (tq - sq).normalized(); - p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200))); - register(ui.ctx(), format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); - grips.push((i, true, sq, r)); - } - pin(p, tq, sel); - register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); - grips.push((i, false, tq, r)); - } - if spot_keys(app, ui, map, d, &grips) { - return; - } - let r = (app.ui.remove_size as f64 * long) as f32; - // a target or source under `q`: the selected spot's first, then the nearest pin - let hit = |q: Pos2| { - grips - .iter() - .filter(|g| g.2.distance(q) < g.3.max(8.0)) - .min_by(|a, b| { - (Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal) - }) - .copied() - }; - let hover = resp.hover_pos().and_then(hit); - if let Some(h) = resp.hover_pos() { - if hover.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } else { - 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.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110))); - } - } - let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); - if resp.drag_started() - && let Some((spot, source, ..)) = press.and_then(hit) - { - if Some(spot) != active { - let _ = app.run("spot.select", json!({"index": spot})); - } - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); - app.gesture = Some(Gesture::SpotMove { spot, source }); - } - if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() { - if resp.dragged() - && let Some(q) = resp.interact_pointer_pos() - { - let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta())); - let dn = [n.x - n0.x, n.y - n0.y]; - if dn != [0.0, 0.0] { - let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn})); - } - } - if resp.drag_stopped() { - app.gesture = None; - let _ = app.run("develop.endInteraction", json!({})); - } - return; - } - if resp.clicked() - && let Some((spot, ..)) = press.and_then(hit) - { - let _ = app.run("spot.select", json!({"index": spot})); - return; - } - if let Some(q) = resp.interact_pointer_pos() - && (resp.drag_started() || resp.dragged() || resp.clicked()) - { - let n = map.norm(q); - match &mut app.gesture { - Some(Gesture::Spot { points }) => points.push(n), - _ => app.gesture = Some(Gesture::Spot { points: vec![n] }), - } - } - if let Some(Gesture::Spot { points }) = &app.gesture { - for q in points { - p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60)); - } - } - if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Spot { points }) = app.gesture.take() - { - let mode = match app.ui.tool.as_str() { - "heal" => "heal", - "clone" => "clone", - "fill" => "fill", - _ => "remove", - }; - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - let _ = app.run( - "spot.add", - json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), - ); - } -} - -/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves -/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move -/// and Return turns into a new spot of the brush's size there. True when a spot was added. -fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool { - for &(spot, source, q, _) in grips { - let at = map.norm(q); - let name = - format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() }); - let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default(); - let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0); - let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q); - if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) { - let _ = app.run("spot.select", json!({"index": spot})); - } - if let Some((dx, dy)) = keys.delta { - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); - let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]})); - let _ = app.run("develop.endInteraction", json!({})); - } - } - // where a new spot goes: the photo's centre at first, then wherever the arrows took it - let key = egui::Id::new("lc-spot-placement"); - let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5)); - let q = map.screen(Point::new(place.0, place.1)); - let keys = crate::canvas_keys::handle( - ui, - "spot:new", - crate::i18n::tr("New spot (Return adds it here)"), - crate::canvas_keys::position_text(place.0, place.1), - q, - ); - if let Some((dx, dy)) = keys.delta { - let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0)); - ui.data_mut(|m| m.insert_temp(key, moved)); - } - if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) { - let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32; - ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE)); - } - if keys.activated { - let mode = match app.ui.tool.as_str() { - "heal" => "heal", - "clone" => "clone", - "fill" => "fill", - _ => "remove", - }; - let _ = app.run( - "spot.add", - json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), - ); - return true; - } - false -} - -// ------------------------------------------------------------------------ red eye - -/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. -fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - // screen points per long-edge unit - let o = map.screen(Point::new(0.0, 0.0)); - let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o)); - let per_long = sx.max(sy); - let p = ui.painter(); - for (i, e) in d.red_eye.iter().enumerate() { - let c = map.screen(e.center); - let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long); - let sel = i == app.ui.eye; - let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) }; - p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col))); - p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col)); - p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col)); - } - if resp.hover_pos().is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - // from where the button went down (a drag is recognized only after some movement) - app.gesture = Some(Gesture::Eye { a: map.norm(q) }); - } - if let Some(Gesture::Eye { a }) = app.gesture - && let Some(q) = resp.interact_pointer_pos() - { - let (a, b) = (map.screen(a), q); - p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE))); - } - if resp.drag_stopped() - && let Some(Gesture::Eye { a }) = app.gesture.take() - && let Some(q) = resp.interact_pointer_pos() - { - let (sa, sb) = (map.screen(a), q); - let half = ((sb - sa) / 2.0).abs() / per_long; - if half.x > 0.002 && half.y > 0.002 { - let c = map.norm(sa.lerp(sb, 0.5)); - let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet})); - if let Ok(v) = r { - app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize; - } - } - } else if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - && let Some(i) = d.red_eye.iter().position(|e| { - let c = map.screen(e.center); - let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0)); - dx * dx + dy * dy <= 1.0 - }) - { - app.ui.eye = i; - } -} - -// ------------------------------------------------------------------------ filmstrip - -/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an -/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266). -fn film_label(name: &str) -> String { - if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::()) } else { name.to_string() } -} - -pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_filled(r, 0.0, t.canvas); - ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); - let ids = app.session.visible_cloned(); - let active = app.session.selection.active; - let cell_w = 120.0; - let ppp = ui.ctx().pixels_per_point(); - if ids.is_empty() { - let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); - return; - } - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); - // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) - child.style_mut().always_scroll_the_only_direction = true; - // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` - let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source); - // a wheel notch scrolls a third of its width; see `grid::wheel_step` - register(ui.ctx(), "filmstrip", child.max_rect()); - let wheel = super::grid::wheel_step(&child, egui::Id::new("film-wheel"), child.max_rect(), child.max_rect().width(), true); - egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { - if wheel != egui::Vec2::ZERO { - ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); - } - let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); - app.film_scroll = Some(vp.left()); - for (i, id) in ids.iter().enumerate() { - let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height())); - let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size()); - // centred when it is (partly) off screen; a click on a visible thumbnail leaves the strip alone - if follow && Some(*id) == active && !(local.left() >= vp.left() && local.right() <= vp.right()) { - ui.scroll_to_rect(cr, Some(egui::Align::Center)); - } - if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) { - continue; - } - let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click()); - let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some(); - crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed)); - register(ui.ctx(), format!("film:{}", id.0), cr); - let state = app.session.selection.state_of(*id); - let sel = state == lightcraft_engine::SelectionState::Active; - let p = ui.painter(); - let label = app.session.catalog.photo(*id).and_then(|ph| ph.label); - let selected = state != lightcraft_engine::SelectionState::NotSelected; - let base = if selected { - t.cell_selected - } else if resp.hovered() { - t.cell_selected.gamma_multiply(0.6) - } else { - t.canvas - }; - p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, selected)); - let names = app.ui.settings.film_names; - if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { - let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); - p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim); - p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim); - } - let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); - super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); - let thumb = app.renderer.thumb(*id); - if thumb.is_none() { - p.rect_filled(img_area, 0.0, t.skeleton); - if failed { - p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text); - } - } - if let Some(tex) = thumb { - let [tw, th] = tex.size; - let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); - let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s)); - p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - if sel { - p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); - } else if state == lightcraft_engine::SelectionState::Selected { - p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside); - } - if app.ui.settings.film_badges - && let Some(ph) = app.session.catalog.photo(*id) - { - film_badges(p, &t, fr, ph); - } - if let Some(st) = app.session.catalog.stack_of(*id) { - let collapsed = st.collapsed; - let text = - if collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; - let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); - let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); - // like the grid's badge: a click expands / collapses the stack - let badge = ui.interact(br, egui::Id::new(("film-stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { - "Stack — click to expand" - } else { - "Stack — click to collapse" - }); - crate::widgets::announce(&badge, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); - register(ui.ctx(), format!("filmstack:{}", id.0), br); - let p = ui.painter(); - let fill = if badge.hovered() { Color32::from_black_alpha(230) } else { Color32::from_black_alpha(170) }; - p.rect_filled(br, 7.5, fill); - crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); - p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - if badge.clicked() { - super::grid::toggle_stack(app, ui.ctx(), *id); - } - } - } - if resp.clicked() { - let m = ui.input(|i| i.modifiers); - let compare = app.ui.view == crate::state::ViewMode::Compare; - let r = if compare && !m.command && !m.shift { - // Compare: the clicked photo replaces the active side (or swaps with the other one) - app.run("compare.set", json!({"id": id.0})) - } else if compare { - // ⌘/Ctrl- and Shift-clicks change the selection; the pair on screen stays (#187) - super::compare::select_with_modifier(app, *id, !m.command) - } else { - let mode = if m.command { - "toggle" - } else if m.shift { - "range" - } else { - "replace" - }; - app.run("library.select", json!({"ids": [id.0], "mode": mode})) - }; - if compare && let Err(e) = r { - app.toast(ui.ctx(), e); - } - } - // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) - crate::widgets::context_menu(&resp, |ui| super::grid::context_menu(app, ui, *id)); - } - }); -} - -/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture -/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the -/// catalog no longer has the photo (or its file name is blank); the reason then stands alone. -pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String { - match (file_name.is_empty(), failed) { - (_, false) => file_name.to_string(), - (true, true) => crate::i18n::tr("This photo can't be opened").to_string(), - (false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")), - } -} - -/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). -fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { - use crate::icons::{Icon, paint}; - use lightcraft_catalog::Flag; - let edited = ph.is_edited(); - if ph.rating == 0 && ph.flag == Flag::None && !edited { - return; - } - let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom()); - p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); - let y = bar.center().y; - let mut x = bar.left() + 3.0; - x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0; - match ph.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), - Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), - Flag::None => {} - } - if edited { - paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label); - } -} - -#[cfg(test)] -mod preview_geometry_tests { - use super::{fit_rect, fit_texture_rect, label_badge_rect}; - use crate::state::Zoom; - use egui::{Rect, pos2, vec2}; - - /// The "Before" label sits on a dark pill that wraps the text with padding on every side. - #[test] - fn before_label_badge_wraps_the_text() { - let text = vec2(40.0, 14.0); - let pane = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); - let bg = label_badge_rect(photo, pane, text, 0.0); - assert_eq!(bg.min, pos2(28.0, 38.0)); - assert_eq!(bg.size(), vec2(56.0, 22.0)); - assert!(photo.contains_rect(bg)); - } - - /// Zoomed in, the photo's corner is off the canvas: the pill moves to the canvas' corner - /// instead of leaving with it (issue #2267), on whichever axes the photo reaches past. - #[test] - fn before_label_badge_stays_in_the_pane_when_the_photo_reaches_past_it() { - let text = vec2(40.0, 14.0); - let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); - let both = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, 0.0); - assert_eq!(both.min, pos2(108.0, 58.0)); - assert!(pane.contains_rect(both)); - let left_only = label_badge_rect(Rect::from_min_size(pos2(-900.0, 120.0), vec2(3000.0, 400.0)), pane, text, 0.0); - assert_eq!(left_only.min, pos2(108.0, 128.0)); - assert!(pane.contains_rect(left_only)); - } - - /// With a filter active, the pill in the canvas' corner has that corner: the label sits below - /// it, whether the photo is past the pane or just under the pill. A photo lower down is left - /// alone. A wide label near the pane's right edge stays inside the pane. - #[test] - 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 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); - assert_eq!(under.min.y, 50.0 + inset + 8.0, "a photo whose corner is under the pill moves down too"); - let lower = label_badge_rect(Rect::from_min_size(pos2(200.0, 200.0), vec2(400.0, 300.0)), pane, text, inset); - assert_eq!(lower.min, pos2(208.0, 208.0)); - let wide = label_badge_rect(Rect::from_min_size(pos2(860.0, 200.0), vec2(400.0, 300.0)), pane, text, 0.0); - assert!(pane.contains_rect(wide), "{wide:?}"); - assert_eq!(wide.max.x, pane.right() - 8.0, "8 points from the pane's right edge, like the left inset"); - } - - #[test] - fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { - let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); - let image = fit_texture_rect(frame, [100, 200]); - - assert!((image.width() / image.height() - 0.5).abs() < 1e-6); - assert_eq!(image.center(), frame.center()); - assert!(frame.contains_rect(image)); - } - - /// A 100 % view must put the photo's corner on a whole physical pixel, or the GPU's bilinear - /// filter softens it (a 1279.5 pt canvas on a 2x display centred the photo half a pixel off). - #[test] - fn percent_zoom_lands_on_whole_physical_pixels() { - let area = Rect::from_min_size(pos2(0.0, 42.0), vec2(1279.5, 726.0)); - for ppp in [1.0, 1.25, 2.0, 3.0] { - for pan in [(0.5, 0.5), (0.3, 0.7), (0.4999, 0.5001)] { - let image = fit_rect(area, 5152.0 / 7728.0, Zoom::Percent(100.0), [5152, 7728], ppp, pan); - for v in [image.min.x, image.min.y] { - assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "{v} pt is not a whole pixel at {ppp}x, pan {pan:?}"); - } - assert!((image.width() * ppp - 5152.0).abs() < 1e-3, "size stays the photo's own pixels"); - } - } - } - - #[test] - fn portrait_preview_aspect_fits_detail_area_without_stretching() { - let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); - let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5)); - - assert!((image.width() / image.height() - 0.5).abs() < 1e-6); - assert_eq!(image.size(), vec2(100.0, 200.0)); - assert_eq!(image.center(), area.center()); - } -} - -/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. -/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. -/// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards. -fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, held: bool) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if !held && resp.double_clicked() { - app.auto_straighten(ui.ctx()); - app.ui.tool.clear(); - app.gesture = None; - return; - } - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - app.gesture = Some(Gesture::StraightenLine { a: q }); - } - let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return }; - let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; - let p = ui.painter(); - p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140))); - p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); - if resp.drag_stopped() { - app.gesture = None; - let v = b - a; - if v.length() > 8.0 { - let mut deg = v.y.atan2(v.x).to_degrees() as f64; - // fold to the nearest axis: a line closer to vertical straightens plumb lines - while deg > 45.0 { - deg -= 90.0; - } - while deg < -45.0 { - deg += 90.0; - } - let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0})); - } - if !held { - app.ui.tool.clear(); - } - } -} - #[cfg(test)] mod tests { /// The rotation pointer's arc bulges away from the side of the crop box it is on, and follows @@ -3376,273 +1011,3 @@ mod tests { assert_eq!(flipped_corners(&hv, true, true), screen); } } - -#[cfg(test)] -mod wheel_zoom_tests { - use super::*; - - fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers } - } - - #[test] - fn only_notched_unmodified_wheels_count() { - let m = egui::Modifiers::NONE; - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0); - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0); - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0); - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0); - } -} - -/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit -/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse. -pub(crate) fn pen_force(i: &egui::InputState) -> Option { - i.events.iter().rev().find_map(|e| match e { - egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), - _ => None, - }) -} - -/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last -/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list. -pub(crate) fn record_pressure(pressure: &mut Vec, index: usize, force: Option) { - match (force, pressure.last().copied()) { - (Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)), - (Some(f), _) => pressure.push(f), - (None, Some(last)) => pressure.push(last), - (None, None) => {} - } -} - -#[cfg(test)] -mod pen_tests { - use super::*; - - #[test] - fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() { - let mut p = Vec::new(); - record_pressure(&mut p, 0, None); - record_pressure(&mut p, 1, None); - assert!(p.is_empty(), "a mouse: no pressure list"); - // the first force covers the points before it, a frame without one repeats the last - record_pressure(&mut p, 2, Some(0.4)); - record_pressure(&mut p, 3, None); - record_pressure(&mut p, 4, Some(0.9)); - assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]); - - let mut i = egui::InputState::default(); - assert_eq!(pen_force(&i), None); - let touch = |force| egui::Event::Touch { - device_id: egui::TouchDeviceId(1), - id: egui::TouchId(1), - phase: egui::TouchPhase::Move, - pos: egui::pos2(1.0, 1.0), - force, - }; - i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)]; - assert_eq!(pen_force(&i), Some(0.3), "the latest usable force"); - i.events = vec![touch(Some(4.0))]; - assert_eq!(pen_force(&i), Some(1.0)); - } -} - -#[cfg(test)] -mod snap_tests { - use super::*; - - /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no - /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. - #[test] - fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { - let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); - let s = snap_fit_rect(r, [1088, 720], 1.0); - assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); - // at 1.5 points per pixel the snap is to physical pixels and the size stays in points - let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); - assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); - let other = snap_fit_rect(r, [2560, 1694], 1.0); - assert_eq!(other, r); - let bad = snap_fit_rect(r, [1088, 720], f32::NAN); - assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); - } -} - -#[cfg(test)] -mod filter_pill_tests { - use super::*; - - /// #1645: the filter pill sits in the loupe's top-left corner as far from the left edge as - /// from the top one. - #[test] - fn the_filter_pill_is_inset_equally_from_both_edges() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - app.session.filter.text = "717".into(); - let ctx = egui::Context::default(); - let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| filter_pill(&mut app, ui, canvas)); - out.textures_delta.clear(); - let widgets = crate::widgets::take_registry(&ctx); - let pill = widgets.iter().find(|(w, _)| w == "loupe:filters").map(|(_, r)| *r).expect("the pill shows with a search"); - let (left, top) = (pill.left() - canvas.left(), pill.top() - canvas.top()); - assert!(top > 0.0, "inside the canvas: {pill:?}"); - assert_eq!(left, top, "left and top insets: {pill:?} in {canvas:?}"); - } -} - -#[cfg(test)] -mod info_overlay_tests { - use super::*; - - /// #1690: the info overlay's shadow is a dark copy under the text. Drawn as a second white - /// copy one point off, each line read doubled. - #[test] - fn the_info_overlay_shadow_is_dark() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - app.ui.info_overlay = crate::state::InfoOverlay::Basic; - let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| info_overlay(&app, ui.painter(), canvas, &photo)); - out.textures_delta.clear(); - // the colour a text shape paints its glyphs in, as epaint resolves it - let ink = |s: &egui::epaint::TextShape| { - let c = s.galley.job.sections.first().map_or(Color32::PLACEHOLDER, |sec| sec.format.color); - s.override_text_color.unwrap_or(if c == Color32::PLACEHOLDER { s.fallback_color } else { c }) - }; - fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { - match shape { - egui::epaint::Shape::Text(t) => out.push(t.clone()), - egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut all = Vec::new(); - out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); - let copies: Vec = all.iter().filter(|t| t.galley.job.text == photo.file_name).map(ink).collect(); - assert_eq!(copies.len(), 2, "the file name and its shadow: {copies:?}"); - assert_eq!(copies[1], Color32::WHITE, "the text on top"); - assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); - } - - /// The lines the overlay paints, top to bottom (each once: the shadow copies left out). - fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); - out.textures_delta.clear(); - let mut lines = Vec::new(); - for c in &out.shapes { - if let egui::epaint::Shape::Text(t) = &c.shape - && t.override_text_color.unwrap_or(t.fallback_color) == Color32::WHITE - { - lines.push(t.galley.job.text.clone()); - } - } - lines - } - - /// View ▸ View Options: Info 1 and Info 2 each show the three lines chosen for them, in that - /// order, without the lines set to None. - #[test] - fn the_info_overlay_shows_the_lines_chosen_in_view_options() { - use crate::state::{InfoOverlay, LoupeInfoField as F}; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); - let size = format!("{} × {}", photo.width, photo.height); - let date = pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)); - app.ui.settings.loupe_info.info1 = [F::Dimensions, F::None, F::FileName]; - app.ui.settings.loupe_info.info2 = [F::CaptureDate, F::FileName, F::None]; - app.ui.info_overlay = InfoOverlay::Basic; - assert_eq!(painted_lines(&app, &photo), vec![size.clone(), photo.file_name.clone()], "Info 1"); - app.ui.info_overlay = InfoOverlay::Exposure; - assert_eq!(painted_lines(&app, &photo), vec![date, photo.file_name.clone()], "Info 2"); - app.ui.settings.loupe_info.info1 = [F::None; 3]; - app.ui.info_overlay = InfoOverlay::Basic; - assert_eq!(painted_lines(&app, &photo), Vec::::new(), "nothing chosen, nothing shown"); - } - - fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - app.ui.info_overlay = crate::state::InfoOverlay::Basic; - let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); - (app, photo) - } - - /// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner. - fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec, Vec<(String, Rect)>) { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); - out.textures_delta.clear(); - fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { - match shape { - egui::epaint::Shape::Text(t) => out.push(t.clone()), - egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut all = Vec::new(); - out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); - (all, crate::widgets::take_registry(&ctx)) - } - - fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 { - let (texts, _) = paint_overlay(app, photo); - texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min) - } - - /// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the - /// file name starts below them; in macOS full screen they are gone and it keeps the corner. - #[test] - fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() { - let (mut app, photo) = demo_app(); - app.ui.fullscreen = true; - assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo"); - app.integrated_titlebar = true; - let bar = Tokens::get(&egui::Context::default()).top_bar_h; - assert!(name_top(&app, &photo) >= bar, "below the window controls"); - app.window_is_fullscreen = true; - assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls"); - app.window_is_fullscreen = false; - app.ui.fullscreen = false; - assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar"); - } - - /// #2460: a rated or flagged photo shows its stars and flag under the date and size. - #[test] - fn the_info_overlay_shows_the_rating_and_flag_under_the_date() { - let (_, mut photo) = demo_app(); - let (app, _) = demo_app(); - photo.rating = 0; - photo.flag = lightcraft_catalog::Flag::None; - let badges = |photo: &lightcraft_catalog::Photo| { - paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r) - }; - assert_eq!(badges(&photo), None, "nothing to show"); - photo.rating = 3; - let stars = badges(&photo).expect("three stars"); - photo.flag = lightcraft_catalog::Flag::Pick; - let flagged = badges(&photo).expect("three stars and a flag"); - assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}"); - let (texts, _) = paint_overlay(&app, &photo); - let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max); - assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut extent = 0.0; - ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear(); - assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}"); - photo.rating = 0; - photo.flag = lightcraft_catalog::Flag::Reject; - assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone"); - } -} diff --git a/crates/ui-egui/src/panels/detail/crop.rs b/crates/ui-egui/src/panels/detail/crop.rs new file mode 100644 index 000000000..295b8f819 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/crop.rs @@ -0,0 +1,582 @@ +use super::geometry::CanvasMap; +use super::*; + +// ------------------------------------------------------------------------ crop + +pub(super) fn crop_overlay( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + frame: &Frame, + d: &DevelopSettings, + id: PhotoId, +) { + // the selected guide belongs to this photo and to the Guided Upright tool, and to a guide + // that still exists (a Delete after the last one leaves the selection behind) + if app.ui.tool != "guidedUpright" || app.ui.selected_guide.is_some_and(|(photo, i)| photo != id.0 || i >= d.geometry.guides.len()) { + app.ui.selected_guide = None; + } + if app.ui.tool == "guidedUpright" { + guided_overlay(app, ui, resp, map, frame, d, id); + return; + } + if app.ui.tool == "straighten" { + straighten_overlay(app, ui, resp, false); + return; + } + // hold ⌘ and drag: draw a straighten line without leaving the crop tool + let cmd_drag = resp.drag_started() && ui.input(|i| i.modifiers.command); + if cmd_drag || matches!(app.gesture, Some(Gesture::StraightenLine { .. })) { + straighten_overlay(app, ui, resp, true); + return; + } + if resp.hovered() && ui.input(|i| i.modifiers.command) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + let quad = frame_crop_quad(d, frame); + let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); + let p = ui.painter(); + // darken outside the crop (the box's corners in screen order: a flipped photo shows its + // top-left corner elsewhere) + let quad = flipped_corners(&pts, frame.flip_h, frame.flip_v); + if pts.len() == 4 { + p.add(outside_crop_mesh(map.rect, &quad, Color32::from_black_alpha(150))); + } + p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE))); + // overlay guides + let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t; + let guide = Stroke::new(0.8, Color32::from_white_alpha(150)); + // (u, v) across / down the (rotated) crop frame + let at = |(u, v): (f32, f32)| pts[0] + (pts[1] - pts[0]) * u + (pts[3] - pts[0]) * v; + let (cw, ch) = (pts[0].distance(pts[1]), pts[0].distance(pts[3])); + for line in crate::panels::crop_overlay::paths(app.ui.crop_overlay, app.ui.crop_overlay_orient, cw, ch) { + p.add(egui::Shape::line(line.into_iter().map(at).collect(), guide)); + } + // handles: 0..3 corners, 4..7 edges (top, right, bottom, left) + let handles: Vec = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect(); + for (i, h) in handles.iter().enumerate() { + register(ui, format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0))); + let s = if i < 4 { 12.0 } else { 9.0 }; + p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE); + p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE); + } + // the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move + if crop_keys(app, ui, map, &handles, &pts, d, frame) { + return; + } + let inside = |q: Pos2| { + let n = map.norm(q); + let s = to_straight(n, d.crop.geometry.angle, frame); + d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y)) + }; + // a crop drag cut short (the tool closed before the release) leaves its gesture behind: with no + // button held it is over + if matches!(app.gesture, Some(Gesture::CropRotate { .. } | Gesture::CropHandle { .. })) + && !resp.drag_stopped() + && !ui.input(|i| i.pointer.any_down()) + { + app.gesture = None; + } + // while rotating, the pointer may leave the canvas: it still shows rotation and the angle + let rotating = matches!(app.gesture, Some(Gesture::CropRotate { .. })); + let pointer = resp.hover_pos().or_else(|| if rotating { ui.input(|i| i.pointer.latest_pos()) } else { None }); + // with ⌘ held a drag draws a level line (the straighten crosshair, set above), not a rotation + let straightening = !rotating && ui.input(|i| i.modifiers.command); + if let Some(hq) = pointer.filter(|_| !straightening) { + // a move or resize drag keeps its pointer wherever it goes, even past the box + let held = match app.gesture { + Some(Gesture::CropHandle { handle, .. }) => Some(usize::from(handle)), + _ => None, + }; + let near = held.or_else(|| handles.iter().position(|h| h.distance(hq) < 12.0)).filter(|_| !rotating); + let cursor = match near { + Some(0 | 2) => egui::CursorIcon::ResizeNwSe, + Some(1 | 3) => egui::CursorIcon::ResizeNeSw, + Some(4 | 6) => egui::CursorIcon::ResizeVertical, + Some(5 | 7) => egui::CursorIcon::ResizeHorizontal, + // 8: the whole box + Some(_) => egui::CursorIcon::Move, + None if inside(hq) && !rotating => egui::CursorIcon::Move, + // a drag here rotates: no system cursor shows that, so draw a curved double arrow + None => { + rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq))); + egui::CursorIcon::None + } + }; + ui.ctx().set_cursor_icon(cursor); + } + // the angle next to the pointer, drawn once this frame's rotation is applied (below) + let readout_at = pointer.filter(|_| rotating); + let mut shown_angle = d.crop.geometry.angle; + // double-click inside the crop box applies the crop (same as Return / Done) + if resp.double_clicked() + && let Some(q) = resp.interact_pointer_pos() + && inside(q) + && !handles.iter().any(|h| h.distance(q) < 12.0) + { + let _ = app.run("tool.done", json!({})); + return; + } + // egui reports a drag only once the pointer has moved a few px: the zone is where the button + // went down, where the cursor showed it, not where the pointer is by then + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) + { + if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) + { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return; + } + let d = app.session.develop_of(id).unwrap_or_default(); + app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) { + Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, + None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, + None => { + let c = map.screen(Point::new(0.5, 0.5)); + Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() } + } + }); + } + if resp.dragged() + && let Some(q) = resp.interact_pointer_pos() + { + match app.gesture.clone() { + Some(Gesture::CropHandle { handle, start, angle }) => { + // The crop model decides what the drag does (anchor, aspect lock, image bounds): see + // `lightcraft_geom::drag_crop`; the UI only reports where the pointer started and is. + let n = to_straight(map.norm(q), angle, frame); + let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n); + // Shift keeps the current proportions for this drag only; the Lock toggle is untouched. + let keep_ratio = ui.input(|i| i.modifiers.shift); + let _ = app.run( + "crop.drag", + json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1], "keepRatio": keep_ratio}), + ); + } + Some(Gesture::CropRotate { start_angle, a0 }) => { + let c = map.screen(Point::new(0.5, 0.5)); + let a = (q - c).angle(); + let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0); + shown_angle = (ang * 100.0).round() / 100.0; + let _ = app.run("crop.straighten", json!({"angle": shown_angle})); + } + _ => {} + } + } + if resp.drag_stopped() { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } + if let Some(at) = readout_at { + // the canvas (the loupe's own rect), not the photo: rotating happens in the margin around it + angle_readout(ui, at, shown_angle, resp.rect); + } +} + +/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 +/// edges (top, right, bottom, left), 8 the whole box. +const CROP_HANDLE_NAMES: [&str; 9] = [ + "Crop top-left corner", + "Crop top-right corner", + "Crop bottom-right corner", + "Crop bottom-left corner", + "Crop top edge", + "Crop right edge", + "Crop bottom edge", + "Crop left edge", + "Crop box", +]; + +/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one +/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the +/// frame is over (the crop was applied). +fn crop_keys( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + map: &CanvasMap, + handles: &[Pos2], + pts: &[Pos2], + d: &DevelopSettings, + frame: &Frame, +) -> bool { + let r = d.crop.geometry.rect; + let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false }; + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1)); + for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() { + let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue }; + let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at); + if keys.activated { + let _ = app.run("tool.done", json!({})); + return true; + } + let Some((dx, dy)) = keys.delta else { continue }; + // the drag runs in the straightened crop frame, as a pointer drag from the handle would; + // the arrows follow the screen + let angle = d.crop.geometry.angle; + let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height()); + let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame)); + let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)); + if let Err(e) = started { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return false; + } + let _ = app.run( + "crop.drag", + json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}), + ); + let _ = app.run("develop.endInteraction", json!({})); + } + false +} + +/// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. +pub(crate) fn crop_angle_label(angle: f64) -> String { + let shown = + lightcraft_develop::controls::find("crop.angle").map_or_else(|| format!("{angle:.2}"), |spec| crate::widgets::shown_value(spec, angle)); + // the slider's format turns a rounded −0.00 into "0": every zero reads "0.00", the angle's + // usual two decimals + let shown = if shown == "0" { "0.00".to_string() } else { shown }; + format!("{shown}°") +} + +/// Where the angle readout of `size` goes for a pointer `at`: below right of it, flipped to the +/// left / above where that would leave `bounds`, then kept inside them. +pub(crate) fn readout_rect(at: Pos2, size: egui::Vec2, bounds: Rect) -> Rect { + // without a real pointer or bounds (NaN, an empty rect) there is nothing to keep it in; and + // f32::clamp panics on NaN, so only finite numbers get there + if !at.is_finite() || !bounds.is_finite() || !bounds.is_positive() { + let at = if at.is_finite() { at } else { bounds.min.max(Pos2::ZERO) }; + let at = if at.is_finite() { at } else { Pos2::ZERO }; + return Rect::from_min_size(at + vec2(18.0, 14.0), size); + } + let x = if at.x + 18.0 + size.x > bounds.right() { at.x - 18.0 - size.x } else { at.x + 18.0 }; + let y = if at.y + 14.0 + size.y > bounds.bottom() { at.y - 14.0 - size.y } else { at.y + 14.0 }; + let x = x.clamp(bounds.left(), (bounds.right() - size.x).max(bounds.left())); + let y = y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top())); + Rect::from_min_size(pos2(x, y), size) +} + +/// On top of everything (the tooltip layer), so neither the photo nor a panel covers it. +fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter { + ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique(name))) +} + +/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo, +/// turned `turn` radians clockwise ([`rotate_cursor_turn`]). +fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) { + let p = top_painter(ui, "crop-rotate-cursor"); + let r = Rect::from_center_size(at, vec2(22.0, 22.0)); + p.circle_filled(at, 12.0, Color32::from_black_alpha(150)); + crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn); + register(ui, "cropRotateCursor", r); +} + +/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges +/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge, +/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest +/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it +/// the other way, still away from that edge. +pub(super) fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 { + let nearest = (0..pts.len()) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + a + ab * t + }) + .min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q))); + let Some(nearest) = nearest else { return 0.0 }; + let away = if inside { nearest - q } else { q - nearest }; + if away.length() < 0.5 { + return 0.0; + } + away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2 +} + +/// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the +/// pointer's other side when it would run past an edge. +fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { + let t = Tokens::get(ui.ctx()); + let p = top_painter(ui, "crop-angle-readout"); + let galley = p.layout_no_wrap(crop_angle_label(angle), t.font(12.5), Color32::WHITE); + let rect = readout_rect(at, galley.size() + vec2(12.0, 6.0), bounds); + p.rect_filled(rect, 4.0, Color32::from_black_alpha(170)); + p.galley(rect.min + vec2(6.0, 3.0), galley, Color32::WHITE); + register(ui, "cropAngleReadout", rect); +} + +/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are +/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps. +fn guided_overlay( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + frame: &Frame, + d: &DevelopSettings, + id: PhotoId, +) { + let p = ui.painter_at(map.rect.expand(8.0)); + let col = Color32::from_rgb(255, 196, 40); + // the selected guide: brighter, so the Delete key has a visible target + let selected = app.ui.selected_guide.filter(|(photo, _)| *photo == id.0).map(|(_, i)| i); + let draw = |a: Pos2, b: Pos2, sel: bool| { + let (edge, core, at) = if sel { (3.5, 2.5, 5.0) } else { (3.0, 1.5, 4.0) }; + p.line_segment([a, b], Stroke::new(edge, Color32::from_black_alpha(120))); + p.line_segment([a, b], Stroke::new(core, if sel { Color32::WHITE } else { col })); + for q in [a, b] { + p.circle_filled(q, at, if sel { Color32::WHITE } else { col }); + p.circle_stroke(q, at, Stroke::new(1.0, Color32::BLACK)); + } + }; + let on_screen = |guide: &(Point, Point)| { + let (sa, sb) = (map.screen(frame.corrected_to_transformed(guide.0)), map.screen(frame.corrected_to_transformed(guide.1))); + (sa, sb) + }; + for (i, guide) in d.geometry.guides.iter().enumerate() { + let (sa, sb) = on_screen(guide); + register(ui, format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb)); + draw(sa, sb, selected == Some(i)); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + // A press starts what it means, right away (the pointer's press point, not where a drag + // threshold would have been crossed): on an endpoint it drags that endpoint (the guide is + // selected when it lands), on a line it selects the guide (a press on the selected one + // deselects), anywhere else it starts a new guide from the press point. The primary button + // only, never on the navigator — it pans the photo, it draws no guide — and only where the + // loupe itself takes the press: `contains_pointer` is layer-aware over the current pointer + // (the raw pointer is not), and `layer_id_at` asks the same question at the press point — + // a menu can receive the press and the pointer can leave it within the same frame, so the + // press itself must belong to the loupe's layer. Anything floating above the photo — a + // context menu, a dropdown, a dialog — owns its own press; the guide under it stays put. + // A slow frame can also swallow the whole press-move-release into one batch, and egui + // clears `press_origin` on the release: then the press position is read back from the + // frame's own events, so the gesture still starts where the pointer went down. + let nav = app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r); + if ui.input(|i| i.pointer.primary_pressed()) + && let Some(q) = ui + .input(|i| { + i.pointer.press_origin().or_else(|| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos), + _ => None, + }) + }) + }) + .or(resp.interact_pointer_pos()) + && resp.contains_pointer() + && ui.ctx().layer_id_at(q) == Some(resp.layer_id) + && resp.rect.contains(q) + && !nav.is_some_and(|n| n.expand(5.0).contains(q)) + { + let endpoint = d.geometry.guides.iter().enumerate().find_map(|(i, guide)| { + let (a, b) = on_screen(guide); + if a.distance(q) <= 7.0 { + Some((i, 0)) + } else if b.distance(q) <= 7.0 { + Some((i, 1)) + } else { + None + } + }); + let line = d.geometry.guides.iter().position(|guide| { + let (a, b) = on_screen(guide); + point_segment_dist(q, a, b) <= 6.0 + }); + match (endpoint, line) { + (Some((i, end)), _) => app.gesture = Some(Gesture::GuideEndpoint { i, end, from: q }), + (None, Some(i)) => app.ui.selected_guide = if selected == Some(i) { None } else { Some((id.0, i)) }, + // drawing a new guide leaves nothing selected + (None, None) => { + app.ui.selected_guide = None; + app.gesture = Some(Gesture::Guide { a: map.norm(q) }); + } + } + } + let q = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); + if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, q) { + draw(map.screen(*a), q, false); + } + // the endpoint being dragged: the guide follows the pointer, bright + if let (Some(Gesture::GuideEndpoint { i, end, .. }), Some(q)) = (&app.gesture, q) + && let Some(guide) = d.geometry.guides.get(*i) + { + let fixed = if *end == 0 { guide.1 } else { guide.0 }; + draw(map.screen(frame.corrected_to_transformed(fixed)), q, true); + } + if ui.input(|i| i.pointer.any_released()) { + match app.gesture.take() { + // the endpoint lands: the guide stays (one undo step) and is selected. A press + // without a real drag only selects — no no-op undo entry for a click. + Some(Gesture::GuideEndpoint { i, end, from }) => { + app.ui.selected_guide = Some((id.0, i)); + if let (Some(q), Some(guide)) = (q, d.geometry.guides.get(i)) { + let fixed_t = if end == 0 { guide.1 } else { guide.0 }; + let new_t = frame.transformed_to_corrected(map.norm(q)); + let (a, b) = if end == 0 { (new_t, fixed_t) } else { (fixed_t, new_t) }; + let (na, nb) = (frame.corrected_to_transformed(a), frame.corrected_to_transformed(b)); + // a press that only selected (it landed within the endpoint's grab radius + // and never moved) writes no guide: compare against where it went down + let moved = from.distance(q) > 2.0; + if moved && na.dist(nb) > 0.02 { + let all: Vec<[f64; 4]> = d + .geometry + .guides + .iter() + .enumerate() + .map(|(j, g)| { + let (x, y) = if j == i { (a, b) } else { *g }; + [x.x, x.y, y.x, y.y] + }) + .collect(); + let _ = app.run("geometry.guides", json!({"guides": all})); + } + } + } + Some(Gesture::Guide { a }) => { + if let Some(q) = q + && map.norm(q).dist(a) > 0.02 + { + let b = map.norm(q); + let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b)); + let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true})); + } + } + other => app.gesture = other, + } + } +} + +/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in. +fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { + let (w, h) = (frame.ow, frame.oh); + let px = Point::new(n.x * w, n.y * h); + let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px); + Point::new(r.x / w, r.y / h) +} + +/// How far `q` is from the segment `a`–`b` (screen pixels): the click that selects a guide. +fn point_segment_dist(q: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(q) +} + +/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) +/// in the order they appear on screen when the photo is shown flipped. +pub(super) fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { + let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO); + let order = match (flip_h, flip_v) { + (false, false) => [0, 1, 2, 3], + (true, false) => [1, 0, 3, 2], + (false, true) => [3, 2, 1, 0], + (true, true) => [2, 3, 0, 1], + }; + order.map(at) +} + +/// The dimming outside the crop box: the photo's rectangle minus the crop quad, as one mesh of +/// non-overlapping convex pieces. The quad is a rotated rectangle, so its opposite sides are +/// parallel and the plane outside it splits into four disjoint half-strips: outside edge `i` +/// and inside edge `i + 1`. Each is clipped to the photo and fanned into triangles. +/// +/// Issue #731: joining photo corner `i` to crop corner `i` with a quad per side only works while +/// the two rectangles are aligned. Rotated, those quads cross themselves and each other, which +/// showed as dimmed wedges inside the crop and doubly dark ones outside, growing with the angle. +/// A degenerate quad (a point, a line, a non-finite corner) dims nothing. +pub(super) fn outside_crop_mesh(img: Rect, quad: &[Pos2; 4], color: Color32) -> egui::epaint::Mesh { + let mut mesh = egui::epaint::Mesh::default(); + if !quad.iter().all(|q| q.x.is_finite() && q.y.is_finite()) || !img.is_finite() { + return mesh; + } + let centre = pos2((quad[0].x + quad[1].x + quad[2].x + quad[3].x) / 4.0, (quad[0].y + quad[1].y + quad[2].y + quad[3].y) / 4.0); + // signed distance from edge i's line, positive towards the crop's centre + let side = |i: usize, q: Pos2| -> Option { + let (a, b) = (quad[i % 4], quad[(i + 1) % 4]); + let n = vec2(a.y - b.y, b.x - a.x); + let orient = n.dot(centre - a); + // an edge the centre lies on: the box is flat + (orient.abs() > 1e-6 * n.length().max(1.0)).then(|| n.dot(q - a) * orient.signum()) + }; + // Sutherland–Hodgman: the part of a convex polygon where `keep` is at or above zero + let clip = |poly: &[Pos2], keep: &dyn Fn(Pos2) -> Option| -> Option> { + let mut out = Vec::with_capacity(poly.len() + 2); + for (k, &a) in poly.iter().enumerate() { + let b = *poly.get((k + 1) % poly.len())?; + let (da, db) = (keep(a)?, keep(b)?); + if da >= 0.0 { + out.push(a); + } + if (da < 0.0) != (db < 0.0) && (da - db).abs() > f32::EPSILON { + let t = da / (da - db); + out.push(a + (b - a) * t); + } + } + Some(out) + }; + let photo = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; + for i in 0..4 { + let outside = |q: Pos2| side(i, q).map(|d| -d); + let inside_next = |q: Pos2| side(i + 1, q); + let Some(strip) = clip(&photo, &outside).and_then(|poly| clip(&poly, &inside_next)) else { return egui::epaint::Mesh::default() }; + if strip.len() < 3 { + continue; + } + let base = mesh.vertices.len() as u32; + mesh.vertices.extend(strip.iter().map(|&pos| egui::epaint::Vertex { pos, uv: Pos2::ZERO, color })); + for k in 1..(strip.len() as u32).saturating_sub(1) { + mesh.indices.extend([base, base + k, base + k + 1]); + } + } + mesh +} + +fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { + let f = Frame { crop: d.crop.geometry, ..frame.clone() }; + f.crop_quad_norm() +} + +/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. +/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. +/// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards. +fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, held: bool) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if !held && resp.double_clicked() { + app.auto_straighten(ui.ctx()); + app.ui.tool.clear(); + app.gesture = None; + return; + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + app.gesture = Some(Gesture::StraightenLine { a: q }); + } + let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return }; + let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + let p = ui.painter(); + p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140))); + p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + if resp.drag_stopped() { + app.gesture = None; + let v = b - a; + if v.length() > 8.0 { + let mut deg = v.y.atan2(v.x).to_degrees() as f64; + // fold to the nearest axis: a line closer to vertical straightens plumb lines + while deg > 45.0 { + deg -= 90.0; + } + while deg < -45.0 { + deg += 90.0; + } + let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0})); + } + if !held { + app.ui.tool.clear(); + } + } +} diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs new file mode 100644 index 000000000..c39fc4442 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -0,0 +1,227 @@ +use super::*; + +/// Size of the Navigator mini map (points). +const NAV_W: f32 = 180.0; + +/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the +/// visible region outlined; click or drag on it to pan. Returns the area it covers. +pub(super) fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option { + let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; + if !app.ui.navigator || !zoomed || app.ui.fullscreen { + return None; + } + let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); + let aspect = img.width() / img.height().max(1.0); + let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) }; + let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h)); + let p = ui.painter_at(canvas); + p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); + match tex { + Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), + None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton), + }; + // the visible part of the photo, in normalized image coordinates + let vis = canvas.intersect(img); + let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height()); + let (a, b) = (n(vis.min), n(vis.max)); + let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height()); + let view = Rect::from_min_max(to_nav(a), to_nav(b)); + p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + register(ui, "canvas:navigator", frame); + let resp = ui.interact(frame.expand(5.0), egui::Id::unique("navigator"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Navigator")); + if (resp.clicked() || resp.dragged()) + && let Some(q) = resp.interact_pointer_pos() + { + // centre the view on the point under the pointer + let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0); + let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); + app.ui.pan = (u, v); + } + Some(frame.expand(5.0)) +} + +// ------------------------------------------------------------------------ filmstrip + +/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an +/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266). +pub(super) fn film_label(name: &str) -> String { + if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::()) } else { name.to_string() } +} + +/// Draw the horizontal filmstrip in `r`: selectable thumbnail cells with names and format when +/// film names are on, badges when film badges are on, and stack counts, following the active +/// photo and scrolling sideways with the wheel. Clicks select: Command toggles and Shift extends +/// the range; in Compare they adjust the pair's selection, and a plain click replaces the active +/// side. +pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(r, 0.0, t.canvas); + ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); + let ids = app.session.visible_cloned(); + let active = app.session.selection.active; + let cell_w = 120.0; + let ppp = ui.ctx().pixels_per_point(); + if ids.is_empty() { + let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); + return; + } + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); + // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) + child.style_mut().always_scroll_the_only_direction = true; + // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` + let follow = crate::panels::grid::follow_active(child.ctx(), egui::Id::unique("film-follow-active"), active, app.session.source); + // a wheel notch scrolls a third of its width; see `grid::wheel_step` + register(ui, "filmstrip", child.max_rect()); + let wheel = crate::panels::grid::wheel_step(&child, egui::Id::unique("film-wheel"), child.max_rect(), child.max_rect().width(), true); + egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { + if wheel != egui::Vec2::ZERO { + ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); + } + let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); + app.film_scroll = Some(vp.left()); + for (i, id) in ids.iter().enumerate() { + let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height())); + let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size()); + // centred when it is (partly) off screen; a click on a visible thumbnail leaves the strip alone + if follow && Some(*id) == active && !(local.left() >= vp.left() && local.right() <= vp.right()) { + ui.scroll_to_rect(cr, Some(egui::Align::Center)); + } + if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) { + continue; + } + let resp = ui.interact(cr, egui::Id::unique(("film", id.0)), Sense::click()); + let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some(); + crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed)); + register(ui, format!("film:{}", id.0), cr); + let state = app.session.selection.state_of(*id); + let sel = state == lightcraft_engine::SelectionState::Active; + let p = ui.painter(); + let label = app.session.catalog.photo(*id).and_then(|ph| ph.label); + let selected = state != lightcraft_engine::SelectionState::NotSelected; + let base = if selected { + t.cell_selected + } else if resp.hovered() { + t.cell_selected.gamma_multiply(0.6) + } else { + t.canvas + }; + p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, selected)); + let names = app.ui.settings.film_names; + if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { + let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); + p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim); + p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim); + } + let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); + crate::panels::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); + let thumb = app.renderer.thumb(*id); + if thumb.is_none() { + p.rect_filled(img_area, 0.0, t.skeleton); + if failed { + p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text); + } + } + if let Some(tex) = thumb { + let [tw, th] = tex.size; + let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); + let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s)); + p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if sel { + p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); + } else if state == lightcraft_engine::SelectionState::Selected { + p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside); + } + if app.ui.settings.film_badges + && let Some(ph) = app.session.catalog.photo(*id) + { + film_badges(p, &t, fr, ph); + } + if let Some(st) = app.session.catalog.stack_of(*id) { + let collapsed = st.collapsed; + let text = + if collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; + let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); + let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); + // like the grid's badge: a click expands / collapses the stack + let badge = ui.interact(br, egui::Id::unique(("film-stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { + "Stack — click to expand" + } else { + "Stack — click to collapse" + }); + crate::widgets::announce(&badge, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); + register(ui, format!("filmstack:{}", id.0), br); + let p = ui.painter(); + let fill = if badge.hovered() { Color32::from_black_alpha(230) } else { Color32::from_black_alpha(170) }; + p.rect_filled(br, 7.5, fill); + crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + if badge.clicked() { + crate::panels::grid::toggle_stack(app, ui.ctx(), *id); + } + } + } + if resp.clicked() { + let m = ui.input(|i| i.modifiers); + let compare = app.ui.view == crate::state::ViewMode::Compare; + let r = if compare && !m.command && !m.shift { + // Compare: the clicked photo replaces the active side (or swaps with the other one) + app.run("compare.set", json!({"id": id.0})) + } else if compare { + // ⌘/Ctrl- and Shift-clicks change the selection; the pair on screen stays (#187) + crate::panels::compare::select_with_modifier(app, *id, !m.command) + } else { + let mode = if m.command { + "toggle" + } else if m.shift { + "range" + } else { + "replace" + }; + app.run("library.select", json!({"ids": [id.0], "mode": mode})) + }; + if compare && let Err(e) = r { + app.toast(ui.ctx(), e); + } + } + // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) + crate::widgets::context_menu(&resp, |ui| crate::panels::grid::context_menu(app, ui, *id)); + } + }); +} + +/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture +/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the +/// catalog no longer has the photo (or its file name is blank); the reason then stands alone. +pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String { + match (file_name.is_empty(), failed) { + (_, false) => file_name.to_string(), + (true, true) => crate::i18n::tr("This photo can't be opened").to_string(), + (false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")), + } +} + +/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). +fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let edited = ph.is_edited(); + if ph.rating == 0 && ph.flag == Flag::None && !edited { + return; + } + let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom()); + p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); + let y = bar.center().y; + let mut x = bar.left() + 3.0; + x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0; + match ph.flag { + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), + Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), + Flag::None => {} + } + if edited { + paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label); + } +} diff --git a/crates/ui-egui/src/panels/detail/geometry.rs b/crates/ui-egui/src/panels/detail/geometry.rs new file mode 100644 index 000000000..6424dabd8 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/geometry.rs @@ -0,0 +1,385 @@ +#[cfg(test)] +use super::info::label_badge_rect; +use super::*; + +/// Screen ↔ normalized-image mapping for the displayed frame. +#[derive(Clone, Copy)] +pub struct CanvasMap { + pub(super) rect: Rect, + to_norm: Affine, + from_norm: Affine, + k: f32, +} + +impl CanvasMap { + pub(super) fn new(frame: &Frame, rect: Rect) -> CanvasMap { + let k = 8.0f32; + let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize); + let to_norm = frame.out_to_norm(w, h); + CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k } + } + pub fn norm(&self, p: Pos2) -> Point { + self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64)) + } + pub fn screen(&self, n: Point) -> Pos2 { + let q = self.from_norm.apply(n); + pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k) + } +} + +pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { + let zoom = match zoom { + Zoom::Percent(p) if !p.is_finite() || p <= 0.0 => Zoom::Fit, + _ => zoom, + }; + let (aw, ah) = (area.width(), area.height()); + let (w, h) = match zoom { + Zoom::Fit => { + if aw / ah > aspect { + (ah * aspect, ah) + } else { + (aw, aw / aspect) + } + } + Zoom::Fill => { + if aw / ah > aspect { + (aw, aw / aspect) + } else { + (ah * aspect, ah) + } + } + Zoom::Percent(p) => { + // the photo's own pixels at p% (its width as shown: cropped and rotated) + let w = img_px[0] as f32 * p / 100.0 / ppp; + (w, w / aspect) + } + }; + let pan = bounded_pan(area, vec2(w, h), pan); + let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h); + let rect = Rect::from_center_size(c, vec2(w, h)); + // At 100 % the texture maps texel for texel to screen pixels, so its corner must sit on a whole + // physical pixel: half a pixel off and the GPU's bilinear filter blurs the view (an odd-width + // canvas on a 2x display does this). Other zooms resample anyway, and a pinch must not jump. + let one_to_one = ppp.is_finite() && ppp > 0.0 && (w * ppp - img_px[0] as f32).abs() < 0.5; + if matches!(zoom, Zoom::Percent(_)) && one_to_one { + let snap = |v: f32| (v * ppp).round() / ppp; + return Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), rect.size()); + } + rect +} + +/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can +/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata. +pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let scale = (area.width() / w).min(area.height() / h); + Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) +} + +/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is +/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred +/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its +/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to +/// the sampler. +pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (w, h) = (size[0] as f32, size[1] as f32); + if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { + return rect; + } + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) +} + +/// Keep the viewport inside the photo; axes smaller than the viewport stay centred. +fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { + let bound = |visible: f32, image: f32, centre: f32| { + let centre = if centre.is_finite() { centre } else { 0.5 }; + if image > visible.max(0.0) { + let half = visible.max(0.0) / image / 2.0; + centre.clamp(half, 1.0 - half) + } else { + 0.5 + } + }; + (bound(area.width(), size.x, pan.0), bound(area.height(), size.y, pan.1)) +} + +pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: egui::Vec2) { + let centre = area.center() - img.min - delta; + let pan = bounded_pan(area, img.size(), (centre.x / img.width().max(1.0), centre.y / img.height().max(1.0))); + let _ = app.run("view.navigate", json!({"pan": pan})); +} + +/// A drag with the middle button (the mouse wheel pressed) pans the zoomed photo whatever tool is +/// active, so the healing brush, mask brushes and crop never paint or move while you look around; +/// the hand cursor shows while it does. +pub(super) fn middle_pan(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, area: Rect, img: Rect) -> bool { + if !resp.dragged_by(egui::PointerButton::Middle) { + return false; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if img.width() > area.width() + 1.0 || img.height() > area.height() + 1.0 { + pan_image(app, area, img, resp.drag_delta()); + } + true +} + +/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. +fn wheel_notches(events: &[egui::Event]) -> f32 { + events + .iter() + .filter_map(|e| match e { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. } + if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() => + { + Some(delta.y) + } + _ => None, + }) + .sum() +} + +/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view. +pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool { + if !resp.hovered() { + return false; + } + let (mut factor, mut delta, anchor) = + ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos()))); + // A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel + // delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan. + let wheel_id = egui::Id::unique("loupe-wheel-zoom"); + if ui.input(|i| wheel_notches(&i.events)) != 0.0 { + ui.data_mut(|d| d.insert_temp(wheel_id, true)); + } + if ui.data(|d| d.get_temp::(wheel_id)).unwrap_or(false) { + let smooth = ui.input(|i| i.smooth_scroll_delta.y); + let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); + if smooth == 0.0 { + ui.data_mut(|d| d.remove_temp::(wheel_id)); + } else { + factor *= (speed * smooth).exp(); + delta = egui::Vec2::ZERO; + } + } + if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) { + return false; + } + let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false }; + let ppp = ui.ctx().pixels_per_point(); + let aspect = img.width() / img.height().max(1.0); + let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5)); + let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32; + let mut zoom = app.ui.zoom; + let size = if factor != 1.0 { + // Start at the displayed scale (including Fit/Fill and an interrupted click animation). + let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0); + zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) }; + fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size() + } else { + img.size() + }; + // The image point under the pointer remains there as its size changes, then follows the pan. + let point = (anchor - img.min) / img.size(); + let offset = area.center() - anchor - delta; + let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0))); + let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan})); + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + ui.ctx().request_repaint(); + true +} + +/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 until it does, the +/// smallest limit WebGL devices have). +/// +/// The value egui keeps, not the frame's raw input: a native host (egui-winit) reports the limit +/// in the first frame's input only, so the raw value is `None` from the second frame on +/// (issue #652: every window render was cut to 2048 px, with blurry strips beside it). +pub(crate) fn texture_side(ctx: &egui::Context) -> usize { + ctx.input(|i| i.max_texture_side) +} + +/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what +/// 100 % zoom and the render size limit are measured against), at least 1 × 1. +pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { + let (w, h) = frame.native_size(); + let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 }; + [px(w), px(h)] +} + +/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the +/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. +pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option { + let f = photo.meta.focus.as_ref()?; + let d = &photo.develop; + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); + let [w, h] = output_px(&frame); + let to_out = frame.out_to_norm(w, h).inverse()?; + // stored on the upright photo: the user's Rotate / Flip on top, then the crop + let r = d.orientation.map_norm_rect(f.rect); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { + let q = to_out.apply(c); + (q.x / w as f64, q.y / h as f64) + }); + let xs = corners.map(|c| c.0); + let ys = corners.map(|c| c.1); + let out = lightcraft_geom::Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { + return None; + } + // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) + let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); + ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) +} + +/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. +pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { + let r = focus_box(app.session.catalog.photo(id)?)?; + Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. +pub(super) fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { + let t = if *anim { 0.22 } else { 0.0 }; + let id = egui::Id::unique("loupe_anim"); + let v = |k: &str, x: f32| ctx.animate_value_with_time(id.with(k), x, t); + let (c, s) = (target.center(), target.size()); + let r = Rect::from_center_size(pos2(v("cx", c.x), v("cy", c.y)), vec2(v("w", s.x), v("h", s.y))); + if *anim && (r.center() - c).abs().max_elem() < 0.5 && (r.size() - s).abs().max_elem() < 0.5 { + *anim = false; + } + r +} + +#[cfg(test)] +mod preview_geometry_tests { + use super::{fit_rect, fit_texture_rect, label_badge_rect}; + use crate::state::Zoom; + use egui::{Rect, pos2, vec2}; + + /// The "Before" label sits on a dark pill that wraps the text with padding on every side. + #[test] + fn before_label_badge_wraps_the_text() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); + let bg = label_badge_rect(photo, pane, text, 0.0); + assert_eq!(bg.min, pos2(28.0, 38.0)); + assert_eq!(bg.size(), vec2(56.0, 22.0)); + assert!(photo.contains_rect(bg)); + } + + /// Zoomed in, the photo's corner is off the canvas: the pill moves to the canvas' corner + /// instead of leaving with it (issue #2267), on whichever axes the photo reaches past. + #[test] + fn before_label_badge_stays_in_the_pane_when_the_photo_reaches_past_it() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); + let both = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, 0.0); + assert_eq!(both.min, pos2(108.0, 58.0)); + assert!(pane.contains_rect(both)); + let left_only = label_badge_rect(Rect::from_min_size(pos2(-900.0, 120.0), vec2(3000.0, 400.0)), pane, text, 0.0); + assert_eq!(left_only.min, pos2(108.0, 128.0)); + assert!(pane.contains_rect(left_only)); + } + + /// With a filter active, the pill in the canvas' corner has that corner: the label sits below + /// it, whether the photo is past the pane or just under the pill. A photo lower down is left + /// alone. A wide label near the pane's right edge stays inside the pane. + #[test] + 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 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); + assert_eq!(under.min.y, 50.0 + inset + 8.0, "a photo whose corner is under the pill moves down too"); + let lower = label_badge_rect(Rect::from_min_size(pos2(200.0, 200.0), vec2(400.0, 300.0)), pane, text, inset); + assert_eq!(lower.min, pos2(208.0, 208.0)); + let wide = label_badge_rect(Rect::from_min_size(pos2(860.0, 200.0), vec2(400.0, 300.0)), pane, text, 0.0); + assert!(pane.contains_rect(wide), "{wide:?}"); + assert_eq!(wide.max.x, pane.right() - 8.0, "8 points from the pane's right edge, like the left inset"); + } + + #[test] + fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { + let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); + let image = fit_texture_rect(frame, [100, 200]); + + assert!((image.width() / image.height() - 0.5).abs() < 1e-6); + assert_eq!(image.center(), frame.center()); + assert!(frame.contains_rect(image)); + } + + /// A 100 % view must put the photo's corner on a whole physical pixel, or the GPU's bilinear + /// filter softens it (a 1279.5 pt canvas on a 2x display centred the photo half a pixel off). + #[test] + fn percent_zoom_lands_on_whole_physical_pixels() { + let area = Rect::from_min_size(pos2(0.0, 42.0), vec2(1279.5, 726.0)); + for ppp in [1.0, 1.25, 2.0, 3.0] { + for pan in [(0.5, 0.5), (0.3, 0.7), (0.4999, 0.5001)] { + let image = fit_rect(area, 5152.0 / 7728.0, Zoom::Percent(100.0), [5152, 7728], ppp, pan); + for v in [image.min.x, image.min.y] { + assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "{v} pt is not a whole pixel at {ppp}x, pan {pan:?}"); + } + assert!((image.width() * ppp - 5152.0).abs() < 1e-3, "size stays the photo's own pixels"); + } + } + } + + #[test] + fn portrait_preview_aspect_fits_detail_area_without_stretching() { + let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); + let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5)); + + assert!((image.width() / image.height() - 0.5).abs() < 1e-6); + assert_eq!(image.size(), vec2(100.0, 200.0)); + assert_eq!(image.center(), area.center()); + } +} + +#[cfg(test)] +mod wheel_zoom_tests { + use super::*; + + fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } + } + + #[test] + fn only_notched_unmodified_wheels_count() { + let m = egui::Modifiers::NONE; + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0); + } +} + +#[cfg(test)] +mod snap_tests { + use super::*; + + /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no + /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. + #[test] + fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { + let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); + let s = snap_fit_rect(r, [1088, 720], 1.0); + assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); + // at 1.5 points per pixel the snap is to physical pixels and the size stays in points + let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); + assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); + let other = snap_fit_rect(r, [2560, 1694], 1.0); + assert_eq!(other, r); + let bad = snap_fit_rect(r, [1088, 720], f32::NAN); + assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); + } +} diff --git a/crates/ui-egui/src/panels/detail/info.rs b/crates/ui-egui/src/panels/detail/info.rs new file mode 100644 index 000000000..520ad6213 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/info.rs @@ -0,0 +1,547 @@ +use super::geometry::focus_box; +use super::*; + +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner +/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. +/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:`). +pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { + let Some(r) = focus_box(photo) else { return }; + let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); + let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); + let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); + let arm = (b.width().min(b.height()) * 0.3).max(5.0); + let green = Color32::from_rgb(90, 230, 110); + for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { + let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; + p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); + p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); + } + let c = b.center(); + p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); + p.circle_filled(c, 1.5, green); + register_geometry(p.ctx(), key, b); +} + +/// View Options' Show Briefly When Photo Changes: note when the loupe changes to photo `id`, and +/// whether the brief info overlay shows this frame (until [`BRIEF_INFO_SECS`] have passed; a +/// repaint is asked for then, so it hides without input). +/// +/// [`BRIEF_INFO_SECS`]: crate::state::BRIEF_INFO_SECS +pub(super) fn brief_info(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + let now = ctx.input(|i| i.time); + let brief = &mut app.ui.brief_info; + if brief.photo != Some(id) { + brief.photo = Some(id); + brief.since = now; + } + let left = crate::state::BRIEF_INFO_SECS - (now - brief.since); + brief.shown = app.ui.settings.loupe_info.briefly != crate::state::InfoOverlay::Off && left > 0.0; + if brief.shown { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } +} + +/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the +/// photo's top left, inset 8 points, with the `text` size plus padding. Zoomed in, the photo +/// reaches past its `pane` (the canvas): the pill then sits at the pane's top left, so the label +/// stays in view (issue #2267: at 100 % it left the screen with the photo's corner), below the +/// `top_inset` the filter pill takes in that corner, and never past the pane's right edge. +pub(super) fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) -> Rect { + let size = text + vec2(16.0, 8.0); + let left = photo.left().max(pane.left()).min(pane.right() - size.x - 16.0).max(pane.left()); + let corner = pos2(left, photo.top().max(pane.top() + top_inset)); + Rect::from_min_size(corner + vec2(8.0, 8.0), size) +} + +/// The info overlay at the canvas' top left (`view.infoOverlay`): the lines View Options chose for +/// Info 1 or Info 2 (`view.viewOptions`). +pub(super) fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { + let Some(lines) = info_overlay_lines(app, photo) else { return }; + let t = Tokens::get(p.ctx()); + let mut y = canvas.top() + info_overlay_top(app, &t); + for (i, l) in lines.iter().enumerate() { + // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white + // layout made the shadow a second white copy (#1690) + let g = p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER); + let at = pos2(canvas.left() + 14.0, y); + // a soft shadow keeps the text readable on bright photos + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g.clone(), Color32::WHITE); + y += g.size().y + INFO_OVERLAY_GAP; + } + y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo); + register_geometry(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); +} + +/// Where the info overlay's text starts below the canvas' top (without window controls over it), +/// and the gap after each line. +const INFO_OVERLAY_TOP: f32 = 12.0; + +const INFO_OVERLAY_GAP: f32 = 3.0; + +/// The info overlay's font for line `i` (the file name is the first, larger line). +fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId { + if i == 0 { t.semibold(15.0) } else { t.font(12.5) } +} + +/// How far below the canvas' top the info overlay's text and badges reach (0 when it is off), so a +/// label drawn in that corner can sit below it. +pub(super) fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 { + let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 }; + let t = Tokens::get(p.ctx()); + let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| { + y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP + }); + text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 } +} + +/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top +/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460). +fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { + let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen; + if controls { t.top_bar_h } else { INFO_OVERLAY_TOP } +} + +/// The info overlay's lines for `photo`, or `None` when it is off. +fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option> { + use crate::state::InfoOverlay; + // with the overlay off, Show Briefly When Photo Changes shows its Info for a moment + let brief = app.ui.info_overlay == InfoOverlay::Off && app.ui.brief_info.shown; + let mode = if brief { app.ui.settings.loupe_info.briefly } else { app.ui.info_overlay }; + let fields = match mode { + InfoOverlay::Off => return None, + InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, + InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, + }; + let lines: Vec = fields.iter().filter_map(|f| info_line(*f, photo)).collect(); + if lines.is_empty() { + return None; + } + Some(lines) +} + +/// The height of the info overlay's rating and flag row. +const OVERLAY_BADGES_H: f32 = 17.0; + +fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool { + photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None +} + +/// The star rating and flag under the info overlay's lines, when the photo has either; returns +/// the y below them. +fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let flag = match photo.flag { + Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)), + Flag::Reject => Some((Icon::FlagReject, t.reject)), + Flag::None => None, + }; + if !has_overlay_badges(photo) { + return at.y; + } + let stars = photo.rating.min(5); + let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize]; + icons.extend(flag); + let side = 12.0; + let mut x = at.x; + for (i, (icon, color)) in icons.into_iter().enumerate() { + // the flag stands a little apart from the stars + if i > 0 && i == stars as usize { + x += 5.0; + } + let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side)); + paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200)); + paint(p, r, icon, color); + x += side + 2.0; + } + register_geometry(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); + at.y + OVERLAY_BADGES_H +} + +/// The filter pill's inset from the canvas' top-left corner and its height (the same inset from +/// the left as from the top, so the pill sits in the corner, #1645). +pub(super) const FILTER_PILL_INSET: f32 = 3.0; + +pub(super) const FILTER_PILL_HEIGHT: f32 = 20.0; + +/// Whether [`filter_pill`] has something to show (a filter is active). +pub(super) fn filter_pill_shown(app: &LightcraftApp) -> bool { + !lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty() +} + +/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous +/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the +/// canvas margin, so the photo does not move. +pub(super) fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); + if chips.is_empty() { + return; + } + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let label = + chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::>().join(" · "); + let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225)); + let inset = FILTER_PILL_INSET; + let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0)); + let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, FILTER_PILL_HEIGHT)); + let resp = ui.interact(r, egui::Id::unique("loupe-filter-pill"), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Filters")); + register(ui, "loupe:filters", r); + p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); + let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); + p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0); + let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text(crate::i18n::tr("Click to clear these filters")).clicked() { + let _ = app.run("library.clearFilter", json!({})); + } +} + +/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; +/// hovering it explains (registered as `notice:previewOnly:loupe`). +pub(super) fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let text = crate::i18n::tr_format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason)); + let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE); + let size = vec2(g.size().x + 40.0, 26.0); + let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size); + p.rect_filled(r, 13.0, Color32::from_black_alpha(185)); + crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(15.0, 15.0)), crate::icons::Icon::Info, t.caution); + p.galley(pos2(r.left() + 29.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); + register(ui, "notice:previewOnly:loupe", r); + ui.interact(r, egui::Id::unique("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason)); +} + +/// One line of the info overlay for `photo`; `None` when the field is None or the photo has +/// nothing to show for it. +fn info_line(field: crate::state::LoupeInfoField, photo: &lightcraft_catalog::Photo) -> Option { + use crate::state::LoupeInfoField as F; + let m = &photo.meta; + let text = match field { + F::None => return None, + F::FileName => photo.file_name.clone(), + F::CaptureDate => pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)), + F::Dimensions => format!("{} × {}", photo.width, photo.height), + F::Exposure => { + let mut exp = Vec::new(); + if !m.shutter.is_empty() { + exp.push(format!("{} s", m.shutter)); + } + if let Some(a) = m.aperture { + exp.push(format!("f/{a:.1}")); + } + if let Some(i) = m.iso { + exp.push(format!("ISO {i}")); + } + if let Some(f) = m.focal_mm { + exp.push(format!("{f:.0} mm")); + } + if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") } + } + F::Camera => m.camera.clone(), + F::Lens => m.lens.clone(), + F::CameraAndLens => [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "), + }; + (!text.is_empty()).then_some(text) +} + +/// `2026-09-30T12:00:00` → `2026-09-30 12:00`. +fn pretty_date(iso: &str) -> String { + let d = iso.replacen('T', " ", 1); + d.get(..16).map(str::to_string).unwrap_or(d) +} + +/// How long a render may be on its way, with nothing new on screen, before the loupe says +/// "Rendering…" (s): as long as the activity stack waits before it shows a task. +pub(super) const RENDERING_AFTER_SECS: f64 = 0.5; + +/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on +/// screen, since when, and whether the pill is up. +pub(crate) type RenderingWait = Option<([Option; 4], f64, bool)>; + +/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with +/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws +/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming: +/// drafts slower than the wait don't make it blink. +pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { + if !coming { + *wait = None; + return false; + } + let (seen, since, up) = wait.get_or_insert((keys, now, false)); + if !*up && *seen != keys { + (*seen, *since) = (keys, now); + } + *up |= now - *since > RENDERING_AFTER_SECS; + *up +} + +/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that +/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The +/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see +/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all +/// there will be. +pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225)); + let inset = if caution { 29.0 } else { 10.0 }; + let size = vec2(g.size().x + inset + 10.0, 20.0); + let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size); + if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) { + r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size); + } + p.rect_filled(r, 10.0, Color32::from_black_alpha(185)); + if caution { + crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution); + } + p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + register(ui, id, r); + r +} + +/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]): +/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with +/// the error on hover and for screen readers. +#[allow(clippy::too_many_arguments)] +pub(crate) fn failed_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + canvas: Rect, + over: Rect, + navigator: Option, + id: &str, + error: &str, +) { + let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path)); + let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" }); + let r = loupe_pill(ui, canvas, over, navigator, id, text, true); + let resp = ui.interact(r, egui::Id::unique(("render-failed", id)), Sense::hover()).on_hover_text(error); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{text}: {error}"))); +} + +pub(super) fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { + use lightcraft_engine::media::QuickSource; + match q { + QuickSource::Cached => "cached", + QuickSource::Embedded => "embedded", + QuickSource::Small => "small", + } +} + +#[cfg(test)] +mod filter_pill_tests { + use super::*; + + /// #1645: the filter pill sits in the loupe's top-left corner as far from the left edge as + /// from the top one. + #[test] + fn the_filter_pill_is_inset_equally_from_both_edges() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.session.filter.text = "717".into(); + let ctx = egui::Context::default(); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| filter_pill(&mut app, ui, canvas)); + out.textures_delta.clear(); + let widgets = crate::widgets::take_registry(&ctx); + let pill = widgets.iter().find(|(w, _)| w == "loupe:filters").map(|(_, r)| *r).expect("the pill shows with a search"); + let (left, top) = (pill.left() - canvas.left(), pill.top() - canvas.top()); + assert!(top > 0.0, "inside the canvas: {pill:?}"); + assert_eq!(left, top, "left and top insets: {pill:?} in {canvas:?}"); + } +} + +#[cfg(test)] +mod info_overlay_tests { + use super::*; + + /// #1690: the info overlay's shadow is a dark copy under the text. Drawn as a second white + /// copy one point off, each line read doubled. + #[test] + fn the_info_overlay_shadow_is_dark() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(&app, ui.painter(), canvas, &photo)); + out.textures_delta.clear(); + // the colour a text shape paints its glyphs in, as epaint resolves it + let ink = |s: &egui::epaint::TextShape| { + let c = s.galley.job.sections.first().map_or(Color32::PLACEHOLDER, |sec| sec.format.color); + s.override_text_color.unwrap_or(if c == Color32::PLACEHOLDER { s.fallback_color } else { c }) + }; + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + let copies: Vec = all.iter().filter(|t| t.galley.job.text == photo.file_name).map(ink).collect(); + assert_eq!(copies.len(), 2, "the file name and its shadow: {copies:?}"); + assert_eq!(copies[1], Color32::WHITE, "the text on top"); + assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); + } + + /// View Options' Show Briefly When Photo Changes: with the overlay off, the chosen Info shows + /// for `BRIEF_INFO_SECS` after the loupe changes photo, then hides; an overlay that is on stays. + #[test] + fn show_briefly_shows_the_info_after_the_photo_changes() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photos: Vec = app.session.catalog.photos().take(2).map(|p| (**p).clone()).collect(); + assert_eq!(photos.len(), 2, "the demo catalog has two photos"); + let ctx = egui::Context::default(); + // the loupe shows photo `i` at `time`: the lines the overlay paints then + let at = |app: &mut LightcraftApp, i: usize, time: f64| { + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, time, vec![]); + let mut out = ctx.run_ui(raw, |ui| brief_info(app, ui.ctx(), photos[i].id)); + out.textures_delta.clear(); + painted_lines(app, &photos[i]) + }; + let name = |i: usize| photos[i].file_name.clone(); + assert!(at(&mut app, 0, 1.0).is_empty(), "off by default"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Exposure; + let info2 = at(&mut app, 1, 2.0); + assert_eq!(info2.first(), Some(&name(1)), "Info 2 shows when the photo changes: {info2:?}"); + assert!(!at(&mut app, 1, 2.0 + BRIEF_INFO_SECS - 0.5).is_empty(), "still up"); + assert!(at(&mut app, 1, 2.0 + BRIEF_INFO_SECS + 0.1).is_empty(), "then hidden"); + assert_eq!(at(&mut app, 0, 10.0).first(), Some(&name(0)), "and shown again for the next photo"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Basic; + let info1 = at(&mut app, 1, 20.0); + assert_eq!(info1.len(), 3, "Info 1's three lines: {info1:?}"); + assert_ne!(info1, info2, "Info 1, not Info 2"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(at(&mut app, 1, 30.0), info2, "an overlay that is on shows its own Info, and stays"); + } + + /// The lines the overlay paints, top to bottom (each once: the shadow copies left out). + fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + let mut lines = Vec::new(); + for c in &out.shapes { + if let egui::epaint::Shape::Text(t) = &c.shape + && t.override_text_color.unwrap_or(t.fallback_color) == Color32::WHITE + { + lines.push(t.galley.job.text.clone()); + } + } + lines + } + + /// View ▸ View Options: Info 1 and Info 2 each show the three lines chosen for them, in that + /// order, without the lines set to None. + #[test] + fn the_info_overlay_shows_the_lines_chosen_in_view_options() { + use crate::state::{InfoOverlay, LoupeInfoField as F}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let size = format!("{} × {}", photo.width, photo.height); + let date = pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)); + app.ui.settings.loupe_info.info1 = [F::Dimensions, F::None, F::FileName]; + app.ui.settings.loupe_info.info2 = [F::CaptureDate, F::FileName, F::None]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), vec![size.clone(), photo.file_name.clone()], "Info 1"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(painted_lines(&app, &photo), vec![date, photo.file_name.clone()], "Info 2"); + app.ui.settings.loupe_info.info1 = [F::None; 3]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), Vec::::new(), "nothing chosen, nothing shown"); + } + + fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + (app, photo) + } + + /// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner. + fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec, Vec<(String, Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + (all, crate::widgets::take_registry(&ctx)) + } + + fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 { + let (texts, _) = paint_overlay(app, photo); + texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min) + } + + /// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the + /// file name starts below them; in macOS full screen they are gone and it keeps the corner. + #[test] + fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() { + let (mut app, photo) = demo_app(); + app.ui.fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo"); + app.integrated_titlebar = true; + let bar = Tokens::get(&egui::Context::default()).top_bar_h; + assert!(name_top(&app, &photo) >= bar, "below the window controls"); + app.window_is_fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls"); + app.window_is_fullscreen = false; + app.ui.fullscreen = false; + assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar"); + } + + /// #2460: a rated or flagged photo shows its stars and flag under the date and size. + #[test] + fn the_info_overlay_shows_the_rating_and_flag_under_the_date() { + let (_, mut photo) = demo_app(); + let (app, _) = demo_app(); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::None; + let badges = |photo: &lightcraft_catalog::Photo| { + paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r) + }; + assert_eq!(badges(&photo), None, "nothing to show"); + photo.rating = 3; + let stars = badges(&photo).expect("three stars"); + photo.flag = lightcraft_catalog::Flag::Pick; + let flagged = badges(&photo).expect("three stars and a flag"); + assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}"); + let (texts, _) = paint_overlay(&app, &photo); + let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max); + assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut extent = 0.0; + ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear(); + assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}"); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::Reject; + assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone"); + } +} diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs new file mode 100644 index 000000000..073de8aa2 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -0,0 +1,657 @@ +use super::geometry::CanvasMap; +use super::*; + +/// What the user did to a face box this frame. +pub(super) enum RegionEdit { + /// The × was clicked. + Remove(usize), + /// A handle drag ended: the region's new box (normalized, upright frame). + Resize(usize, lightcraft_geom::Rect), + /// A name was typed or picked for the face. + Name(usize, String), + /// The name box's offer to set up face recognition was pressed. + SetUp, +} + +/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show. +const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ + (0.0, 0.0, egui::CursorIcon::ResizeNwSe), + (0.5, 0.0, egui::CursorIcon::ResizeVertical), + (1.0, 0.0, egui::CursorIcon::ResizeNeSw), + (1.0, 0.5, egui::CursorIcon::ResizeHorizontal), + (1.0, 1.0, egui::CursorIcon::ResizeNwSe), + (0.5, 1.0, egui::CursorIcon::ResizeVertical), + (0.0, 1.0, egui::CursorIcon::ResizeNeSw), + (0.0, 0.5, egui::CursorIcon::ResizeHorizontal), +]; + +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows +/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, +/// 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. +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], + editing: &mut Option, + setup: crate::panels::faces::Setup, +) -> Option { + let t = Tokens::get(p.ctx()); + let clip = p.clip_rect(); + let pointer = ui.input(|i| i.pointer.hover_pos()); + // the box being dragged: (region index, its box so far) + let drag_key = egui::Id::unique("region-drag"); + let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key)); + let mut edit = None; + for (index, r) in photo.meta.regions.iter().enumerate() { + let dragging = live.filter(|(i, _)| *i == index); + let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); + let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); + // white with a black keyline just outside it, so the box shows on any background + p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside); + if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) { + for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { + let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); + let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::unique(("region-handle", index, h)), Sense::drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Resize region")); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(*cursor); + } + let sq = Rect::from_center_size(c, vec2(7.0, 7.0)); + p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE }); + p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside); + if resp.dragged() + && let Some(pp) = resp.interact_pointer_pos() + { + // move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one + let mut n = rect; + if *fx == 0.0 { + n.min.x = pp.x.min(n.max.x - 12.0); + } else if *fx == 1.0 { + n.max.x = pp.x.max(n.min.x + 12.0); + } + if *fy == 0.0 { + n.min.y = pp.y.min(n.max.y - 12.0); + } else if *fy == 1.0 { + n.max.y = pp.y.max(n.min.y + 12.0); + } + let (a, b) = (map.norm(n.min), map.norm(n.max)); + let shown = lightcraft_geom::Rect { + x0: a.x.min(b.x).clamp(0.0, 1.0), + y0: a.y.min(b.y).clamp(0.0, 1.0), + x1: a.x.max(b.x).clamp(0.0, 1.0), + y1: a.y.max(b.y).clamp(0.0, 1.0), + }; + // back to the upright frame the region is stored in + let new = orient.inverse().map_norm_rect(shown); + ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); + ui.ctx().request_repaint(); + } + if resp.drag_stopped() { + if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) { + edit = Some(RegionEdit::Resize(i, new)); + } + ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key)); + } + } + } + if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { + let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); + let resp = ui.interact(xr, egui::Id::unique(("region-x", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); + register(ui, format!("regionRemove:{index}"), xr); + let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); + p.rect_filled(xr, 2.0, fill); + p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); + let (c, m) = (xr.center(), 3.5); + let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text(crate::i18n::tr("Remove this face box (undo with Edit ▸ Undo)")).clicked() { + edit = Some(RegionEdit::Remove(index)); + } + } + // what the label says: the name, a guess to confirm ("Jane Doe?"), or on hover an invitation to name the face + let is_face = r.kind == lightcraft_meta::RegionKind::Face; + let hint = hints.and_then(|h| h.by_index.get(&index)); + let hovered = pointer.is_some_and(|h| rect.expand(3.0).contains(h)); + let editing_this = editing.as_ref().is_some_and(|e| e.photo == photo.id.0 && e.index == index); + let (text, text_color, fill_alpha, invitation) = match (&r.name, hint.and_then(|h| h.suggestion.as_ref())) { + (Some(n), _) => (n.clone(), Color32::from_gray(225), 235, false), + (None, Some((n, _))) if is_face => (format!("{n}?"), Color32::from_gray(185), 200, false), + (None, None) if is_face && live.is_none() => ("Add name".to_string(), Color32::from_gray(150), 190, true), + _ => continue, + }; + // the name in a dark label with a caret, centred above the box (below it when there is no room) + let name = &text; + let g = p.layout_no_wrap(name.clone(), t.font(13.0), text_color); + let (pad, caret) = (vec2(14.0, 7.0), 5.0); + let size = g.size() + pad * 2.0; + let above = rect.top() - caret - size.y >= clip.top(); + let (top, tip, base) = if above { + (rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0) + } else { + (rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0) + }; + let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left())); + let label = Rect::from_min_size(pos2(left, top), size); + // the invitation shows while the pointer is on the box *or on the label* (and the gap between), so it can be reached + if invitation && !(hovered || editing_this || pointer.is_some_and(|h| rect.union(label).expand(3.0).contains(h))) { + continue; + } + let fill = Color32::from_rgba_unmultiplied(56, 56, 56, fill_alpha); + p.rect_filled(label, 3.0, fill); + p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside); + let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0); + p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE)); + p.galley(label.min + pad, g, text_color); + if is_face && live.is_none() { + // a click on the label opens the name box, filled with the name or the guess + let resp = ui.interact(label, egui::Id::unique(("region-label", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face")); + register(ui, format!("regionLabel:{index}"), label); + if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() { + let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); + *editing = Some(crate::state::NameEdit { photo: photo.id.0, index, text: start, fresh: true }); + } + } + if editing_this && let Some(e) = editing.as_mut() { + let candidates = hint.map(|h| h.candidates.as_slice()).unwrap_or(&[]); + match crate::panels::faces::name_editor(ui.ctx(), pos2(label.left(), rect.bottom() + 8.0), e, candidates, people, setup) { + crate::panels::faces::Editor::Submit(name) => edit = Some(RegionEdit::Name(index, name)), + crate::panels::faces::Editor::Setup => edit = Some(RegionEdit::SetUp), + crate::panels::faces::Editor::Cancel => *editing = None, + crate::panels::faces::Editor::Open => {} + } + } + } + edit +} + +// ------------------------------------------------------------------------ masks + +/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. +pub(crate) fn component_pin(shape: &MaskShape) -> Option { + match shape { + MaskShape::Radial { center, .. } => Some(*center), + MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), + MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), + _ => None, + } +} + +/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. +fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { + let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); + match shape.clone() { + MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle { + 1 | 3 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_rx = (dx * co + dy * s).abs().max(0.01); + let mut new_ry = ry; + if alt { + new_ry = ry * (new_rx / rx.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_ry = new_rx; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 2 | 4 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_ry = (dx * (-s) + dy * co).abs().max(0.01); + let mut new_rx = rx; + if alt { + new_rx = rx * (new_ry / ry.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_rx = new_ry; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 5 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let new_angle = dy.atan2(dx).to_degrees(); + MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert } + } + _ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + }, + MaskShape::Linear { start, end } => match handle { + 1 => MaskShape::Linear { start: at, end }, + 2 => MaskShape::Linear { start, end: at }, + _ => MaskShape::Linear { start: mv(start), end: mv(end) }, + }, + MaskShape::Brush { mut strokes } => { + for st in &mut strokes { + st.points.iter_mut().for_each(|p| *p = mv(*p)); + } + MaskShape::Brush { strokes } + } + s => s, + } +} + +/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units, +/// so undo the same scaling and rotation used to draw its outline before testing containment. +fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { + let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false }; + if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 { + return false; + } + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let x = (dx * co + dy * s) / rx; + let y = (-dx * s + dy * co) / ry; + x * x + y * y <= 1.0 +} + +/// Colour Range eyedropper: a click samples the colour under it for the selected mask's +/// colour range, at normalized frame coordinates (crop, rotation and zoom aware); +/// ⇧-click adds a sample (up to 5). +pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) { + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let add = ui.input(|i| i.modifiers.shift); + if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) { + app.toast(ui.ctx(), e); + } + } +} + +/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask +/// 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); + // (mask, component, handle, screen position) of everything that can be grabbed + let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); + // pins follow Show Pins and the Tool Overlay mode (Shift+H); Auto hides them while brushing + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + let mut pins = p.clone(); + if mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Brush { .. })) { + pins.set_invisible(); + } + for m in &d.masks { + let sel = Some(m.id) == active; + // Ctrl+Shift+O (`mask_overlay_all`) shows every visible mask's region; outline each one + // too, not just the selected mask. Handles and the rotation grip stay on the selected + // 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 app.ui.mask_pins + && mode.shows(sel, live) + && let Some(at) = component_pin(&c.shape) + { + let q = map.screen(at); + pin(&pins, q, sel); + register(ui, format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 0, q)); + } + } + } + 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) + 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() { + if let MaskShape::Object { hint, exclude, .. } = &c.shape { + let (hint, exclude) = match pending.as_ref().filter(|q| (q.mask, q.comp) == (m.id, k)) { + Some(q) => (&q.hint, &q.exclude), + None => (hint, exclude), + }; + for (pts, col) in [(hint, Color32::from_rgb(40, 200, 90)), (exclude, Color32::from_rgb(230, 60, 60))] { + for q in pts { + let q = map.screen(*q); + p.circle_filled(q, 5.0, col); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + } + } + } + } + } + // 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 + 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 }); + } + } + } + // 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; + } + // the grip under the pointer: the selected mask's first, then the nearest + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.3.distance(q) < 10.0) + .min_by(|a, b| { + (Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some((mid, ..)) = hit(q) + && Some(mid) != active + { + let _ = app.run("mask.select", json!({"id": mid})); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { + let m = d.masks.iter().find(|m| Some(m.id) == active)?; + // Handles take precedence; otherwise grab the selected radial component under + // the press, including components after the first in compound masks. + let at = map.norm(q); + if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) { + return Some((m.id, ci, 0)); + } + // anywhere on the photo drags the selected linear gradient + matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) + }); + if let Some((mask, comp, handle)) = grip + && let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()) + { + if Some(mask) != active { + let _ = app.run("mask.select", json!({"id": mask})); + } + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); + app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape }); + } + } + if resp.dragged() + && let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + { + let n = map.norm(q); + let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q); + let n0 = map.norm(press_origin); + let dn = Point::new(n.x - n0.x, n.y - n0.y); + let alt = ui.input(|i| i.modifiers.alt); + let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt); + let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); + } + if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } +} + +/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial +/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end. +fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String { + let part = match (shape, handle) { + (_, 0) => "pin", + (MaskShape::Radial { .. }, 1) => "right edge", + (MaskShape::Radial { .. }, 2) => "bottom edge", + (MaskShape::Radial { .. }, 3) => "left edge", + (MaskShape::Radial { .. }, 4) => "top edge", + (MaskShape::Radial { .. }, _) => "rotation", + (MaskShape::Linear { .. }, 1) => "start", + (MaskShape::Linear { .. }, _) => "end", + _ => "handle", + }; + format!("{mask} {}", crate::i18n::tr(part)) +} + +/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a +/// step (one undo step), as dragging it would; Return on a pin selects its mask. +fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) { + for &(mid, ci, handle, q) in grips { + let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue }; + let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue }; + let at = map.norm(q); + let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() }; + let name = mask_handle_name(&mask_name, shape, handle); + let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q); + if keys.activated && app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let Some((dx, dy)) = keys.delta else { continue }; + if app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let to = Point::new(at.x + dx, at.y + dy); + let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false); + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); + let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape})); + let _ = app.run("develop.endInteraction", json!({})); + } +} + +/// Long-edge units → normalized units for x and y. +fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { + // derive from the mapping: 1 normalized unit in x and y in screen points + let o = map.screen(Point::new(0.0, 0.0)); + let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64; + let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64; + let l = x.max(y); + (l / x.max(1e-9), l / y.max(1e-9)) +} + +/// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is +/// being dragged. +pub(super) fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool { + resp.hover_pos().is_some_and(|q| map.rect.contains(q)) + || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) + // a handle moved with the keyboard stays shown while it has the focus + || crate::canvas_keys::has_focus(&resp.ctx) +} + +pub(super) fn pin(p: &egui::Painter, c: Pos2, sel: bool) { + p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) }); + p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE)); +} + +/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit +/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse. +pub(crate) fn pen_force(i: &egui::InputState) -> Option { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) +} + +/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last +/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list. +pub(crate) fn record_pressure(pressure: &mut Vec, index: usize, force: Option) { + match (force, pressure.last().copied()) { + (Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)), + (Some(f), _) => pressure.push(f), + (None, Some(last)) => pressure.push(last), + (None, None) => {} + } +} + +#[cfg(test)] +mod pen_tests { + use super::*; + + #[test] + fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() { + let mut p = Vec::new(); + record_pressure(&mut p, 0, None); + record_pressure(&mut p, 1, None); + assert!(p.is_empty(), "a mouse: no pressure list"); + // the first force covers the points before it, a frame without one repeats the last + record_pressure(&mut p, 2, Some(0.4)); + record_pressure(&mut p, 3, None); + record_pressure(&mut p, 4, Some(0.9)); + assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]); + + let mut i = egui::InputState::default(); + assert_eq!(pen_force(&i), None); + let touch = |force| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase: egui::TouchPhase::Move, + pos: egui::pos2(1.0, 1.0), + force, + }; + i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)]; + assert_eq!(pen_force(&i), Some(0.3), "the latest usable force"); + i.events = vec![touch(Some(4.0))]; + assert_eq!(pen_force(&i), Some(1.0)); + } +} diff --git a/crates/ui-egui/src/panels/detail/retouch.rs b/crates/ui-egui/src/panels/detail/retouch.rs new file mode 100644 index 000000000..64f4c2281 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/retouch.rs @@ -0,0 +1,249 @@ +use super::geometry::CanvasMap; +use super::masks::{pin, pins_live}; +use super::*; + +// ------------------------------------------------------------------------ remove + +/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), +/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. +/// The Tool Overlay mode (Shift+H) says which spots show; a hidden spot can't be grabbed, so a +/// click there adds a new one. +pub(super) fn remove_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_spot.filter(|i| *i < d.spots.len()); + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + // Auto also hides them while a new spot is painted + let painting = mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Spot { .. })); + let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); + let mut pins = p.clone(); + if painting { + pins.set_invisible(); + } + // (spot, is source, screen centre, radius) of everything that can be grabbed + let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); + for (i, s) in d.spots.iter().enumerate() { + let r = (s.size * long) as f32; + let sel = Some(i) == active; + if !mode.shows(sel, live) { + continue; + } + let p = &pins; + let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); + for q in &s.points { + p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); + } + let Some(&t) = s.points.first() else { continue }; + let tq = map.screen(t); + if let Some(o) = s.source_offset.filter(|_| sel) { + let sq = map.screen(Point::new(t.x + o.x, t.y + o.y)); + for q in &s.points { + p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170))); + } + let dir = (tq - sq).normalized(); + p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200))); + register(ui, format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); + grips.push((i, true, sq, r)); + } + pin(p, tq, sel); + register(ui, format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); + grips.push((i, false, tq, r)); + } + if spot_keys(app, ui, map, d, &grips) { + return; + } + let r = (app.ui.remove_size as f64 * long) as f32; + // a target or source under `q`: the selected spot's first, then the nearest pin + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.2.distance(q) < g.3.max(8.0)) + .min_by(|a, b| { + (Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + let hover = resp.hover_pos().and_then(hit); + if let Some(h) = resp.hover_pos() { + if hover.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } else { + 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.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110))); + } + } + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some((spot, source, ..)) = press.and_then(hit) + { + if Some(spot) != active { + let _ = app.run("spot.select", json!({"index": spot})); + } + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + app.gesture = Some(Gesture::SpotMove { spot, source }); + } + if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() { + if resp.dragged() + && let Some(q) = resp.interact_pointer_pos() + { + let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta())); + let dn = [n.x - n0.x, n.y - n0.y]; + if dn != [0.0, 0.0] { + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn})); + } + } + if resp.drag_stopped() { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } + return; + } + if resp.clicked() + && let Some((spot, ..)) = press.and_then(hit) + { + let _ = app.run("spot.select", json!({"index": spot})); + return; + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + match &mut app.gesture { + Some(Gesture::Spot { points }) => points.push(n), + _ => app.gesture = Some(Gesture::Spot { points: vec![n] }), + } + } + if let Some(Gesture::Spot { points }) = &app.gesture { + for q in points { + p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60)); + } + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Spot { points }) = app.gesture.take() + { + let mode = match app.ui.tool.as_str() { + "heal" => "heal", + "clone" => "clone", + "fill" => "fill", + _ => "remove", + }; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); + } +} + +/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves +/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move +/// and Return turns into a new spot of the brush's size there. True when a spot was added. +fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool { + for &(spot, source, q, _) in grips { + let at = map.norm(q); + let name = + format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() }); + let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default(); + let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0); + let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q); + if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) { + let _ = app.run("spot.select", json!({"index": spot})); + } + if let Some((dx, dy)) = keys.delta { + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]})); + let _ = app.run("develop.endInteraction", json!({})); + } + } + // where a new spot goes: the photo's centre at first, then wherever the arrows took it + let key = egui::Id::unique("lc-spot-placement"); + let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5)); + let q = map.screen(Point::new(place.0, place.1)); + let keys = crate::canvas_keys::handle( + ui, + "spot:new", + crate::i18n::tr("New spot (Return adds it here)"), + crate::canvas_keys::position_text(place.0, place.1), + q, + ); + if let Some((dx, dy)) = keys.delta { + let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0)); + ui.data_mut(|m| m.insert_temp(key, moved)); + } + if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) { + let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32; + ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE)); + } + if keys.activated { + let mode = match app.ui.tool.as_str() { + "heal" => "heal", + "clone" => "clone", + "fill" => "fill", + _ => "remove", + }; + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); + return true; + } + false +} + +// ------------------------------------------------------------------------ red eye + +/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. +pub(super) fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + // screen points per long-edge unit + let o = map.screen(Point::new(0.0, 0.0)); + let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o)); + let per_long = sx.max(sy); + let p = ui.painter(); + for (i, e) in d.red_eye.iter().enumerate() { + let c = map.screen(e.center); + let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long); + let sel = i == app.ui.eye; + let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) }; + p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col))); + p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col)); + p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col)); + } + if resp.hover_pos().is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + // from where the button went down (a drag is recognized only after some movement) + app.gesture = Some(Gesture::Eye { a: map.norm(q) }); + } + if let Some(Gesture::Eye { a }) = app.gesture + && let Some(q) = resp.interact_pointer_pos() + { + let (a, b) = (map.screen(a), q); + p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE))); + } + if resp.drag_stopped() + && let Some(Gesture::Eye { a }) = app.gesture.take() + && let Some(q) = resp.interact_pointer_pos() + { + let (sa, sb) = (map.screen(a), q); + let half = ((sb - sa) / 2.0).abs() / per_long; + if half.x > 0.002 && half.y > 0.002 { + let c = map.norm(sa.lerp(sb, 0.5)); + let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet})); + if let Ok(v) = r { + app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize; + } + } + } else if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some(i) = d.red_eye.iter().position(|e| { + let c = map.screen(e.center); + let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0)); + dx * dx + dy * dy <= 1.0 + }) + { + app.ui.eye = i; + } +} diff --git a/crates/ui-egui/src/panels/detail/window.rs b/crates/ui-egui/src/panels/detail/window.rs new file mode 100644 index 000000000..0b441913b --- /dev/null +++ b/crates/ui-egui/src/panels/detail/window.rs @@ -0,0 +1,119 @@ +use super::*; + +/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) +/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize +/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high +/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin +/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels. +pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { + use lightcraft_pipeline::Overlay as O; + match overlay { + O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, + } +} + +/// What a window render of the loupe needs to know about the view it belongs to. +pub(super) struct WindowCtx { + pub(super) id: PhotoId, + pub(super) overlay: lightcraft_pipeline::Overlay, + /// Long edge of the frame the windows are cut from (`None`: no windows). + pub(super) frame_edge: Option, + pub(super) drawn_long: f32, + pub(super) aspect: f32, + pub(super) ppp: f32, + pub(super) texture_side: usize, + pub(super) crop_tool: bool, + pub(super) interacting: bool, + /// Hash of the photo's develop settings. + pub(super) look: u64, + /// A pinch or two-finger scroll is running: keep the windows there are. + pub(super) holding: bool, +} + +/// One side of the loupe that gets a window render. +#[derive(Clone, Copy)] +pub(super) struct WindowView { + pub(super) slot: Slot, + /// The Before side (the photo without its edits). + pub(super) before: bool, + /// The rect the photo is drawn in. + pub(super) img: Rect, + /// The rect it is requested for (the drawn rect without a click-zoom animation). + pub(super) target: Rect, + /// The area whose pixels are on screen. + pub(super) visible: Rect, +} + +/// Ask for the window of `v` that holds what is on screen; remember it so its texture can be +/// drawn where it belongs. `None`: no window is wanted (or possible) for this view. +pub(super) fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { + let frame_edge = c.frame_edge?; + // while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one + // per frame (zooming out would ask for ever wider ones) + if c.holding { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); + } + let (fw, fh) = + if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) }; + let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); + // what is on screen, in pixels of the window's frame (the drawn frame, scaled) + let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0); + let visible = ( + to_px * (v.visible.left() - v.target.left()), + to_px * (v.visible.top() - v.target.top()), + to_px * (v.visible.right() - v.target.left()), + to_px * (v.visible.bottom() - v.target.top()), + ); + let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; + // a window waits for the original another window job is decoding (see `defer_window`) + let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); + let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore); + if crate::region::defer_window(window_pending, original_held) { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); + } + let job = if v.before { + app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) + } else { + app.session.region_job(c.id, fw, fh, win, !c.crop_tool) + }; + // refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one + // render, so the whole frame is rendered at the drawn size instead (see `region::plan`) + let Some(job) = job else { + app.window_refused = Some((c.id, c.look, frame_edge)); + return None; + }; + // a drag renders drafts of the window, as it does of the whole frame. The Before tile stays + // plain; the After tile carries the same mask overlay as the whole-frame render. + let job = if c.interacting { job.draft() } else { job }; + let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; + let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; + // the After window is proofed like the whole-frame render under it; the Before side is + // never proofed (its whole-frame render isn't either) + let proof = app.ui.soft_proof.then_some(app.ui.proof); + let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof }); + // keyed as the renderer keys the texture (a display profile changes the key) + let job = app.renderer.job_for(v.slot, job); + // The overlay and the proof are part of the tile's identity too, so changing mask view/colour + // or the proof never leaves a stale region tile on screen while its replacement renders. + let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) }; + let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41); + let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; + // windows of other photos, looks and zooms of this side can't be drawn any more + app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); + app.region_tiles.insert((v.before, job.key), view); + app.renderer.request(v.slot, job, 99); + Some(view) +} + +/// Draw `v`'s window texture over the whole-frame render, if it is the one asked for (or, in a +/// drag, an earlier draft of it). +pub(super) fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) { + let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return }; + let Some(tile) = app.region_tiles.get(&(v.before, tex.key)).filter(|t| t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) else { + return; + }; + let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); + let at = v.img.min + vec2(tile.window.x as f32 / fw * v.img.width(), tile.window.y as f32 / fh * v.img.height()); + let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * v.img.width(), tile.window.h as f32 / fh * v.img.height())); + p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 2063580ae..fb47fc148 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -17,63 +17,22 @@ use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register, section_header, slider, text_button}; -/// Commit a slider interaction: begin → live updates → end, so a drag is one undo step. -pub fn apply_slider_out( - app: &mut LightcraftApp, - spec: &ControlSpec, - out: SliderOut, - mut set: impl FnMut(&mut LightcraftApp, f64) -> Result, -) { - if out.drag_started && !out.reset { - let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); - app.ui.dragging_control = Some(spec.id.to_string()); - } - if let Some(v) = out.value { - let _ = set(app, v); - } - if out.drag_stopped || out.reset { - let _ = app.run("develop.endInteraction", json!({})); - app.ui.dragging_control = None; - } -} +mod curve; +mod grading; +mod histogram; +mod mixer; +mod sliders; -pub(crate) fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { - let Some(spec) = controls::find(id) else { return }; - let v = controls::get(d, id).unwrap_or(spec.default); - let out = slider(ui, spec, v, enabled, None); - apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": id, "value": v}))); -} - -/// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift. -const REL_TEMP: ControlSpec = ControlSpec { - id: "wb.tempRel", - label: "Temp", - section: Section::Color, - min: -100.0, - max: 100.0, - default: 0.0, - step: 1.0, - decimals: 0, - track: Track::Temp, -}; -const REL_TINT: ControlSpec = ControlSpec { - id: "wb.tintRel", - label: "Tint", - section: Section::Color, - min: -100.0, - max: 100.0, - default: 0.0, - step: 1.0, - decimals: 0, - track: Track::Tint, -}; - -fn k_to_rel(k: f64) -> f64 { - ((1e6 / 6500.0 - 1e6 / k) / 0.8).clamp(-100.0, 100.0) -} -fn rel_to_k(r: f64) -> f64 { - 1e6 / (1e6 / 6500.0 - r * 0.8) -} +use curve::curve_editor; +use grading::grading; +pub(crate) use grading::wheel_pointer_frame_end; +#[cfg(test)] +pub(crate) use grading::{WheelGrab, WheelLock, WheelMods, wheel_pick}; +use histogram::{histogram, soft_proofing}; +use mixer::{mixer, point_color}; +use sliders::{REL_TEMP, REL_TINT, hdr_row, k_to_rel, profile_menu, quick_develop, rel_to_k, reset_tip, section}; +pub use sliders::{apply_slider_out, sub_title}; +pub(crate) use sliders::{control, reload_preview_only}; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); @@ -199,7 +158,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { let mut on = d.profile.camera_local_tone; let r = ui.checkbox(&mut on, crate::i18n::tr("Camera local tone mapping")); - crate::widgets::register(ui.ctx(), "check:profile.cameraLocalTone", r.rect); + crate::widgets::register(ui, "check:profile.cameraLocalTone", r.rect); if r.on_hover_text(crate::i18n::tr("Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it")).changed() { let _ = app.run("develop.merge", json!({"settings": {"profile": {"camera_local_tone": on}}, "label": "Camera Local Tone Mapping"})); } @@ -429,1684 +388,3 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("develop.reset", json!({})); } } - -/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and -/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and -/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the -/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. -fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { - crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); -} - -fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { - use lightcraft_engine::presets::{PROFILES, profile_groups}; - let t = Tokens::get(ui.ctx()); - ui.set_min_width(200.0); - let cur = d.profile.id.as_str(); - // (clicked, hovered) - let mut pick: (Option, Option) = (None, None); - let item = |ui: &mut egui::Ui, key: &str, id: &str, name: &str, pick: &mut (Option, Option)| { - let r = ui.selectable_label(id == cur, crate::i18n::profile_label(id, name)); - register(ui.ctx(), format!("profileMenu:{key}:{id}"), r.rect); - if r.clicked() { - pick.0 = Some(id.to_string()); - } - if r.hovered() { - pick.1 = Some(id.to_string()); - } - }; - let heading = |ui: &mut egui::Ui, s: &str| { - ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim)); - }; - for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { - let list: Vec<_> = ids.iter().filter_map(|id| app.session.profile_info(id).map(|(name, _)| (id.as_str(), name))).collect(); - if list.is_empty() { - continue; - } - heading(ui, title); - for (id, name) in list { - item(ui, key, id, name, &mut pick); - } - ui.separator(); - } - for g in profile_groups() { - let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { - crate::menu_level::level(ui, 2, Some(below), |ui| { - ui.set_min_width(170.0); - for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p.id, p.name, &mut pick); - } - }); - }); - register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); - if r.inner.is_some() { - crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); - } - } - // imported LUT profile groups - let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); - lut_groups.sort_unstable(); - lut_groups.dedup(); - for g in &lut_groups { - // a folder's name as it is, and its own widget id even when it matches a built-in group - let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { - crate::menu_level::level(ui, 2, Some(below), |ui| { - ui.set_min_width(170.0); - for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { - item(ui, "group", &p.id, &p.name, &mut pick); - } - }); - }); - register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); - if r.inner.is_some() { - crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); - } - } - ui.separator(); - if let Some((name, _)) = app.session.profile_info(cur) { - let fav = app.session.profile_favorites.iter().any(|f| f == cur); - let label = if fav { - crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::profile_label(cur, name)) - } else { - crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::profile_label(cur, name)) - }; - let r = ui.button(label); - register(ui.ctx(), "profileMenu:toggleFavorite", r.rect); - if r.clicked() { - let _ = app.run("profile.favorite", json!({"id": cur})); - } - } - let r = ui.button(crate::i18n::tr("Browse…")); - register(ui.ctx(), "profileMenu:browse", r.rect); - if r.clicked() { - let _ = app.run("panel.profiles", json!({})); - } - // resting on a profile previews it in the loupe - if let Some(hovered_id) = &pick.1 - && hovered_id != cur - && let Some((name, _)) = app.session.profile_info(hovered_id) - { - let mut s = d.clone(); - s.profile.id = hovered_id.clone(); - s.profile.amount = 100.0; - app.hover_preview = - Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(hovered_id, name)), settings: s }); - } - if let Some(id) = pick.0 { - let _ = app.run("develop.profile", json!({"id": id})); - } -} - -pub fn sub_title(ui: &mut egui::Ui, title: &str) { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); - ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label); -} - -fn section( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - d: &DevelopSettings, - id: &str, - title: &str, - body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui, &DevelopSettings), -) { - let open = app.ui.section_open(id); - let (resp, toggled) = section_header(ui, id, title, open, Some(d.section_enabled(id))); - if let Some(on) = toggled { - let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on})); - } else if resp.clicked() { - // ⌥-click opens just this section, as in Lightroom Classic - let solo = ui.input(|i| i.modifiers.alt); - if let Err(e) = app.run(&format!("section.{id}"), json!({"solo": solo})) { - app.toast(ui.ctx(), e); - } - } - crate::widgets::context_menu(&resp, |ui| { - let mut solo = app.ui.single_panel; - let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode")); - register(ui.ctx(), "sectionMenu:solo", item.rect); - if item.clicked() { - if let Err(e) = app.run("view.soloSections", json!({"on": solo})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }); - if open { - body(app, ui, d); - } - divider(ui); -} - -// ------------------------------------------------------------------------------ histogram - -/// The current photo's existing preview histogram, shared by Histogram and Tone Curve. -fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option { - app.renderer - .textures - .get(&Slot::Main) - .filter(|t| t.photo == id) - .and_then(|t| t.histogram.clone()) - .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())) -} - -/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's -/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to -/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so -/// the histogram still follows the edit live; an SDR edit shows what it gets. -fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option) -> Option { - let key = egui::Id::new(("hdrHistogram", id)); - match hist { - Some(h) if h.hdr.is_some() => { - ctx.data_mut(|d| d.insert_temp(key, h.clone())); - Some(h) - } - h if hdr_edit => ctx.data(|d| d.get_temp::(key)).or(h), - h => h, - } -} - -fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); - // a drag of an HDR edit: its drafts have no HDR histogram, so a small render beside them - // gives one (at its own pace: the drafts don't wait for it) - let interacting = app.session.interaction.is_some(); - if interacting && hdr_edit { - let edge = lightcraft_pipeline::HDR_HISTOGRAM_DRAG_EDGE; - if let Some(job) = app.session.render_job(id, edge, edge, false, app.ui.right != RightPanel::Crop) - && app.renderer.live_hdr_histogram.as_ref().is_none_or(|l| l.1 != job.key) - { - app.renderer.request(Slot::HdrHistogram, job, 95); - } - } - let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); - // else the loupe render of this photo, else its thumbnail (grid view before the loupe has - // rendered it) - let hist = live.or_else(|| preview_histogram(app, id)); - let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist); - // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label - // row under the plot - let hdr = hist.as_ref().and_then(|h| h.hdr); - let legend = if hdr.is_some() { 26.0 } else { 0.0 }; - let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); - register(ui.ctx(), "histogram", r); - let p = ui.painter(); - p.rect_filled(r, 0.0, t.button); - let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend)); - // Lightroom Classic's tonal zones (Blacks / Shadows / Exposure / Highlights / Whites across the - // plot): a drag over one moves its control (the band and the readout show which and how far), a - // double-click puts it back to its default, and Shift drags a tenth as far - let zone = super::histogram::interact(app, ui, &resp, plot, id); - if let Some(a) = &zone { - super::histogram::paint_zone(p, plot, a, &t); - } - if let Some(b) = hdr { - // white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR - // range on) in its colours - let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; - let bar = |a: f32, z: f32, c: Color32| p.rect_filled(Rect::from_min_max(pos2(a, plot.bottom() + 2.0), pos2(z, plot.bottom() + 7.0)), 0.0, c); - let white = Color32::from_rgb(228, 228, 230); - bar(plot.left(), x0 - 1.0, white); - let stops = b.stops.round().max(1.0) as usize; - let bands = lightcraft_pipeline::visualize::HDR_BANDS; - let colours = app.ui.hdr_visualize; - for k in 0..stops { - let c = if colours { bands.get(k).or(bands.last()).copied().unwrap_or([255, 255, 255]) } else { [228, 228, 230] }; - let (a, z) = (x0 + (plot.right() - x0) * k as f32 / stops as f32, x0 + (plot.right() - x0) * (k + 1) as f32 / stops as f32); - bar(a + 1.0, z - 1.0, Color32::from_rgb(c[0], c[1], c[2])); - } - let y = plot.bottom() + 18.0; - p.text(pos2((plot.left() + x0) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("SDR"), t.font(12.5), t.text_label); - p.text(pos2((x0 + plot.right()) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("HDR"), t.font(12.5), t.text_label); - let info = Rect::from_center_size(pos2(plot.right() - 16.0, y), vec2(14.0, 14.0)); - paint(p, info, Icon::Info, t.text_label); - ui.interact(info, egui::Id::new("hdrHistogramInfo"), Sense::hover()).on_hover_text(crate::i18n::tr( - "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", - )); - } - let clipping = hist.as_ref().map(|h| h.clipping()); - if let Some(h) = hist { - let smooth = |v: &[u32]| -> Vec { - (0..256) - .map(|i| { - let (a, b) = (i.max(2) - 2, (i + 3).min(256)); - v[a..b].iter().map(|x| *x as f32).sum::() / (b - a) as f32 - }) - .collect() - }; - // HDR: SDR on the left half, a solid line at SDR white and dashed ones between the stops - if let Some(b) = h.hdr { - let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; - let stops = b.stops.round().max(1.0) as usize; - for k in 1..stops { - let x = x0 + (plot.right() - x0) * k as f32 / stops as f32; - let mut y = plot.top(); - while y < plot.bottom() { - p.line_segment([pos2(x, y), pos2(x, (y + 5.0).min(plot.bottom()))], Stroke::new(1.0, Color32::from_gray(150))); - y += 9.0; - } - } - p.line_segment([pos2(x0, plot.top()), pos2(x0, plot.bottom())], Stroke::new(1.5, Color32::from_gray(160))); - // the photo's headroom limit on the fixed scale: marked, the range past it dimmed - let limit = app.session.develop_of(id).map_or(b.stops, |d| d.hdr.max_ev as f32); - if limit.is_finite() && limit >= 0.0 && limit < b.stops - 0.05 { - let x = x0 + (plot.right() - x0) * (limit / b.stops).clamp(0.0, 1.0); - p.rect_filled(Rect::from_min_max(pos2(x, plot.top()), plot.max), 0.0, Color32::from_black_alpha(70)); - p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, Color32::from_rgb(230, 150, 60))); - } - } - let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))]; - let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b)); - let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0)); - // luminance fill - let lum = smooth(&h.luma); - let mut mesh = Mesh::default(); - for i in 0..256 { - let top = to(i, lum[i]); - mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); - mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); - if i > 0 { - let k = (i as u32) * 2; - mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); - } - } - p.add(mesh); - // greyscale images: the three channels coincide — draw one neutral line - let spread: f32 = (0..256).map(|i| (chans[0].0[i] - chans[1].0[i]).abs() + (chans[1].0[i] - chans[2].0[i]).abs()).sum(); - let total: f32 = chans[1].0.iter().sum::().max(1.0); - if spread / total < 0.02 { - let pts: Vec = (0..256).map(|i| to(i, chans[1].0[i])).collect(); - p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_gray(200)))); - } else { - for (v, c) in &chans { - let pts: Vec = (0..256).map(|i| to(i, v[i])).collect(); - p.add(egui::Shape::line(pts, Stroke::new(1.5, *c))); - } - } - } - let mut clicked = resp.clicked(); - // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep - // clear of it, the left one too so they stay symmetric (#1640) - let [shadow_rect, highlight_rect] = super::histogram::clipping_rects(ui, plot); - for (name, rect, label, clipped) in [ - ("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), - ("highlights", highlight_rect, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), - ] { - let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click()); - register(ui.ctx(), format!("histogram:{name}"), rect); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); - clicked |= response.clicked(); - let active = app.ui.show_clipping; - // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. - let box_rect = rect.shrink2(vec2(2.0, 2.0)); - p.rect_filled( - box_rect, - 3.0, - if active { - t.pressed - } else if response.hovered() { - t.hover - } else { - Color32::from_gray(60) - }, - ); - if active { - p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - } - paint( - p, - Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), - Icon::ChevronDown, - if clipped == Some(true) || active { t.text } else { t.text_dim }, - ); - let status = match clipped { - Some(true) => "Clipping detected in this preview.", - Some(false) => "No clipping detected in this preview.", - None => "Histogram not yet available.", - }; - response.on_hover_text(format!( - "{}\n{}\n{}", - crate::i18n::tr(label), - crate::i18n::tr(if active { - "Click to hide clipping warnings for shadows and highlights." - } else { - "Click to show clipping warnings for shadows and highlights." - }), - crate::i18n::tr(status) - )); - } - if clicked { - let _ = app.run("view.clipping", json!({})); - } - // the tonal zone the pointer is on replaces the EXIF strip while it is there - if let Some(text) = zone.as_ref().and_then(|a| a.readout()) { - register(ui.ctx(), "histogramReadout", r); - p.text(pos2(r.left() + r.width() / 2.0, r.bottom() - 13.0), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); - } else if let Some(ph) = app.session.catalog.photo(id) { - let m = &ph.meta; - let parts: Vec = [ - m.iso.map(|i| format!("ISO {i}")), - m.focal_mm.map(|f| format!("{f:.0}mm")), - m.aperture.map(|a| format!("f/{a:.1}")), - if m.shutter.is_empty() { None } else { Some(m.shutter.clone()) }, - ] - .into_iter() - .flatten() - .collect(); - - if !parts.is_empty() { - let text = parts.join(" · "); - let y = r.bottom() - 13.0; - let x = r.left() + r.width() / 2.0; - p.text(pos2(x, y), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); - } - } -} - -// ------------------------------------------------------------------------------ soft proofing - -/// The Soft Proofing strip under the histogram: proof profile, gamut warnings, Create Proof Copy. -fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - use lightcraft_engine::pipeline::OutputSpace; - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Soft Proofing")).color(t.text).strong()); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut dest = app.ui.proof.dest_warning; - let r = ui - .toggle_value(&mut dest, RichText::new("■").color(Color32::from_rgb(255, 40, 40))) - .on_hover_text(crate::i18n::tr("Show destination gamut warning")); - register(ui.ctx(), "button:proofDestWarning", r.rect); - let mut disp = app.ui.proof.display_warning; - let r2 = ui - .toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))) - .on_hover_text(crate::i18n::tr("Show display gamut warning")); - register(ui.ctx(), "button:proofDisplayWarning", r2.rect); - if dest != app.ui.proof.dest_warning || disp != app.ui.proof.display_warning { - let _ = app.run("view.softProof", json!({"destWarning": dest, "displayWarning": disp})); - } - }); - }); - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label)); - let cur = app.ui.proof.space; - let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); - let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); - let mut pick = false; - crate::widgets::combo_box("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { - for s in OutputSpace::ALL { - if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { - let _ = app.run("view.softProof", json!({"space": s})); - } - } - if let Some(n) = &device { - let _ = ui.selectable_label(true, n); - } - if app.services.pick_display_profile.is_some() && ui.selectable_label(false, crate::i18n::tr("Other…")).clicked() { - pick = true; - } - }); - if pick - && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) - && let Err(e) = app.run("view.softProof", json!({"profile": path})) - { - app.toast(ui.ctx(), e); - } - }); - if app.ui.proof_profile.is_some() { - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Intent:")).color(t.text_label)); - use lightcraft_engine::proof::ProofIntent; - for (i, label) in [(ProofIntent::Perceptual, "Perceptual"), (ProofIntent::Relative, "Relative")] { - let r = ui.selectable_label(app.ui.proof_intent == i, crate::i18n::tr(label)); - register(ui.ctx(), format!("button:proofIntent-{}", i.name()), r.rect); - if r.clicked() && app.ui.proof_intent != i { - let _ = app.run("view.softProof", json!({"intent": i.name()})); - } - } - }); - let mut paper = app.ui.proof_paper_ink; - let r = ui.checkbox(&mut paper, crate::i18n::tr("Simulate Paper & Ink")); - register(ui.ctx(), "button:proofPaperInk", r.rect); - if r.changed() { - let _ = app.run("view.softProof", json!({"paperInk": paper})); - } - } - let r = ui.button(crate::i18n::tr("Create Proof Copy")); - register(ui.ctx(), "button:createProofCopy", r.rect); - if r.clicked() { - let target = app.ui.proof_profile.as_ref().map_or_else(|| app.ui.proof.space.label().to_string(), |(_, n)| n.clone()); - let name = crate::i18n::tr_format!("Proof Copy ({})", target); - if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { - app.toast(ui.ctx(), crate::i18n::tr("Proof copy created")); - } - } - }); - divider(ui); -} - -// ------------------------------------------------------------------------------ tone curve - -/// A 256-point, peak-normalized display profile of the photo histogram. -/// For HDR, only the SDR portion maps to a curve's input (0..1). -fn curve_histogram_profile(h: &lightcraft_raster::Histogram, channel: &str) -> [f32; 256] { - let source = match channel { - "red" => &h.r, - "green" => &h.g, - "blue" => &h.b, - _ => &h.luma, - }; - let bins = h.hdr.map_or(256, |hdr| hdr.from.clamp(2, 256)); - let samples: [f32; 256] = std::array::from_fn(|i| { - let j = i * (bins - 1) / 255; - let start = j.saturating_sub(2); - let end = (j + 3).min(bins); - let sum: f32 = (start..end).map(|k| source.get(k).copied().unwrap_or(0) as f32).sum(); - sum / (end - start) as f32 - }); - let peak = samples.iter().copied().fold(1.0_f32, f32::max); - samples.map(|v| (v / peak).sqrt()) -} - -/// Show the currently rendered photo's tonal distribution behind the editable -/// curve, without requesting another render or blocking pointer interactions. -fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histogram, channel: &str) { - let profile = curve_histogram_profile(h, channel); - let color = match channel { - "red" => hex("#dd3333"), - "green" => hex("#33bb55"), - "blue" => hex("#3377ee"), - _ => Color32::from_gray(130), - } - .gamma_multiply(0.27); - let mut mesh = Mesh::default(); - for (i, height) in profile.into_iter().enumerate() { - let x = r.left() + r.width() * i as f32 / 255.0; - let top = pos2(x, r.bottom() - r.height() * height); - mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color }); - mesh.vertices.push(Vertex { pos: pos2(x, r.bottom()), uv: Pos2::ZERO, color }); - if i > 0 { - let k = i as u32 * 2; - mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); - } - } - p.add(mesh); -} - -/// Segments the graph's curve line is drawn with. -pub(crate) const CURVE_SAMPLES: i32 = 96; - -/// Close a graph drag's `develop` interaction, as one undo step. -/// -/// It is closed when the drag ends *or* the pointer comes up without egui reporting `drag_stopped` -/// for this widget (released outside the window, over a popup, or on a frame the widget does not -/// run). A dangling interaction would make every later edit a silent preview — Undo would then -/// appear to do nothing at all. Returns whether the drag is over, so the caller clears its state. -fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, stopped: bool) -> bool { - let released = ui.input(|i| i.pointer.any_released()) || !ui.input(|i| i.pointer.primary_down()); - if !dragging || !(stopped || released) { - return false; - } - let _ = app.run("develop.endInteraction", json!({})); - true -} - -/// How close (screen points) to the curve a press counts as grabbing it. -const PARAM_GRAB: f32 = 14.0; - -/// The parametric curve's own drag: the graph shows the curve and nothing else, and dragging the -/// line bends the region the pointer is over, with that region's slider following on the same frame. -/// -/// The whole gesture runs inside one `develop` interaction, so it is one undo step (Ctrl+Z takes the -/// gesture back, not the last frame of it), and every write goes through `develop.set`, so the -/// slider, the curve and what is drawn are the same state rather than three things kept in step. -fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { - if r.width() <= 1.0 || r.height() <= 1.0 { - return; - } - let t = Tokens::get(ui.ctx()); - let model = ParametricCurve::new(&d.curve); - let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); - let from_screen_x = |q: Pos2| ((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; - // the graph x of a point, and how far it is from the drawn curve (in screen points) - let near_curve = |q: Pos2| { - let x = from_screen_x(q); - to_screen(x, model.at(x)).distance(q) - }; - let drag_id = egui::Id::new("curve-param-drag"); - let cursor_id = egui::Id::new("curve-param-cursor"); - // (region, x, initial_amount, start_y): the region being dragged, the graph x the drag is anchored - // at, the region's starting amount, and the starting screen Y position. - let mut dragged: Option<(usize, f64, f64, f32)> = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); - let hovered = dragged.is_none() && resp.hover_pos().is_some_and(|q| near_curve(q) <= PARAM_GRAB); - let mut set: Option<(&'static str, f64)> = None; - // the press, not where the pointer is once the drag threshold has passed: a press slightly off - // the line is still a grab, and the pointer has usually left that spot by the time it starts - let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); - if resp.drag_started() - && let Some(q) = press - && near_curve(q) <= PARAM_GRAB - { - let x = from_screen_x(q); - let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"})); - let region = model.region_at(x); - let initial_amount = model.amounts.get(region).copied().unwrap_or(0.0); - let start_pos = to_screen(x, model.at(x)); - dragged = Some((region, x, initial_amount, start_pos.y)); - ui.data_mut(|dd| dd.insert_temp(cursor_id, start_pos)); - } - if let Some((region, x, initial_amount, start_y)) = dragged - && resp.dragged() - { - // Follow the pointer's motion from where the curve was grabbed, so the press itself moves - // nothing and the line tracks the hand. ⌥ = a tenth of the motion, as the point curve does. - let alt = ui.input(|i| i.modifiers.alt); - let dd = ui.input(|i| i.pointer.delta()); - let step = if alt { vec2(dd.x * 0.1, dd.y * 0.1) } else { vec2(dd.x, dd.y) }; - let cursor = ui.data(|d| d.get_temp::(cursor_id)).unwrap_or(pos2(0.0, start_y)) + step; - ui.data_mut(|d| d.insert_temp(cursor_id, cursor)); - let delta_y = start_y - cursor.y; - let delta_level = (delta_y / r.height()) as f64; - let per_unit = model.level_per_unit(region, x); - let new_amount = - if per_unit.abs() > f64::EPSILON { (initial_amount + delta_level / per_unit).clamp(-100.0, 100.0).round() } else { initial_amount }; - if let Some(&id) = PARAM_AMOUNTS.get(region) { - set = Some((id, new_amount)); - } - } - if let Some((id, v)) = set { - let _ = app.run("develop.set", json!({"control": id, "value": v})); - } - let dropped = resp.drag_stopped(); - if commit_interaction(app, ui, dragged.is_some(), dropped) { - dragged = None; - ui.data_mut(|d| { - d.remove::(cursor_id); - d.remove::>(drag_id); - }); - } - ui.data_mut(|dd| dd.insert_temp(drag_id, dragged)); - if dragged.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else if hovered { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - // the region under the pointer (or the one in hand), named as its slider below reads it - let shown = dragged.map(|(region, ..)| region).or_else(|| hovered.then(|| resp.hover_pos()).flatten().map(|q| model.region_at(from_screen_x(q)))); - if let Some(region) = shown - && let Some(&id) = PARAM_AMOUNTS.get(region) - && let Some(v) = set.and_then(|(s_id, v)| (s_id == id).then_some(v)).or_else(|| controls::get(d, id)) - { - let name = controls::find(id).map_or("", |s| s.label); - ui.painter().text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, format!("{name} {v:.0}"), t.font(11.0), t.text_dim); - } -} - -fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); - // channel selector - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - for (ch, c) in [ - ("parametric", Color32::from_gray(200)), - ("master", Color32::WHITE), - ("red", hex("#dd3333")), - ("green", hex("#33bb55")), - ("blue", hex("#3377ee")), - ] { - let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); - crate::widgets::announce(&resp, format!("{} {ch}", crate::i18n::tr("Curve channel"))); - register(ui.ctx(), format!("curveChannel:{ch}"), r); - let sel = app.ui.curve_channel == ch; - if ch == "parametric" { - paint(ui.painter(), r.shrink(4.0), Icon::Curve, if sel { t.text } else { t.icon }); - } else { - ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(2.0, c)); - if sel { - ui.painter().circle_filled(r.center(), 3.5, c); - } - } - if sel { - ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text)); - } - let name = match ch { - "parametric" => "Parametric curve", - "master" => "Point curve", - "red" => "Red channel", - "green" => "Green channel", - _ => "Blue channel", - }; - let resp = resp.on_hover_text(crate::i18n::tr_format!("{name} — double-click to reset it", name = name)); - if resp.double_clicked() { - app.ui.curve_channel = ch.into(); - let _ = app.run("curve.reset", json!({"channel": ch})); - } else if resp.clicked() { - app.ui.curve_channel = ch.into(); - } - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there"); - }); - }); - }); - let ch = app.ui.curve_channel.clone(); - let w = ui.available_width(); - let side = (w - 48.0).max(80.0); - let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover()); - let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); - let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); - register(ui.ctx(), "curve", r); - crate::widgets::context_menu(&resp, |ui| curve_reset_menu(app, ui, &ch)); - let p = ui.painter(); - p.rect_filled(r, 2.0, t.canvas); - if let Some(hist) = preview_histogram(app, id) { - draw_curve_histogram(p, r, &hist, &ch); - } - for i in 1..4 { - let f = i as f32 / 4.0; - p.line_segment( - [pos2(r.left() + r.width() * f, r.top()), pos2(r.left() + r.width() * f, r.bottom())], - Stroke::new(1.0, Color32::from_gray(52)), - ); - p.line_segment( - [pos2(r.left(), r.top() + r.height() * f), pos2(r.right(), r.top() + r.height() * f)], - Stroke::new(1.0, Color32::from_gray(52)), - ); - } - p.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.0, Color32::from_gray(70))); - let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); - let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64); - if ch == "parametric" { - // The Effective curve: the model the renderer builds from the four region sliders, sampled - // here and rebuilt every frame. The graph holds no state of its own, so a slider moved by - // hand, a Reset or an Undo redraws it from the settings in the same frame it changes. - let c = &d.curve; - let model = ParametricCurve::new(c); - let pts: Vec = - (0..=CURVE_SAMPLES).map(|i| to_screen(i as f64 / CURVE_SAMPLES as f64, model.at(i as f64 / CURVE_SAMPLES as f64))).collect(); - p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220)))); - param_drag(app, ui, r, &resp, d); - curve_footer(app, ui, d); - for &c in PARAM_AMOUNTS.iter().rev() { - control(app, ui, d, c, true); - } - control(app, ui, d, "curve.refineSaturation", true); - return; - } - let pts_of = |d: &DevelopSettings| -> Vec { - let v = match ch.as_str() { - "red" => &d.curve.red, - "green" => &d.curve.green, - "blue" => &d.curve.blue, - _ => &d.curve.master, - }; - // in the order the curve is drawn (points from a sidecar, a preset or an agent can come - // unsorted or not finite): the drag below keeps each point between its neighbours - let mut v: Vec = v.iter().copied().filter(|q| q.x.is_finite() && q.y.is_finite()).collect(); - v.sort_by(|a, b| a.x.total_cmp(&b.x)); - if v.len() < 2 { vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)] } else { v } - }; - let pts = pts_of(d); - let curve = lightcraft_color::spline::MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::>()); - let color = match ch.as_str() { - "red" => hex("#dd3333"), - "green" => hex("#33bb55"), - "blue" => hex("#3377ee"), - _ => Color32::from_gray(225), - }; - let line: Vec = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect(); - p.add(egui::Shape::line(line, Stroke::new(2.0, color))); - // interaction: drag a point (both axes, between its neighbours; ⌥ = fine), drag empty space to add and - // drag a new point, click empty space to add, double-click a point to delete it - let drag_id = egui::Id::new(("curve-drag", &ch)); - // the point being dragged (stored as `Option` so a stale value can be cleared) - let mut dragging: Option = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); - let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1)); - let hovered = resp.hover_pos().and_then(nearest).filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i); - if dragging.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else if hovered.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - for (i, q) in pts.iter().enumerate() { - let c = to_screen(q.x, q.y); - if dragging == Some(i) || (dragging.is_none() && hovered == Some(i)) { - p.circle_filled(c, 5.5, color); - p.circle_stroke(c, 5.5, Stroke::new(1.0, t.canvas)); - } else { - p.circle_filled(c, 4.0, color); - } - } - // input / output readout of the point being dragged (0–255 like the histogram) - if let Some(q) = dragging.and_then(|i| pts.get(i)) { - p.text( - r.left_top() + vec2(6.0, 4.0), - egui::Align2::LEFT_TOP, - format!("{} / {}", (q.x * 255.0).round(), (q.y * 255.0).round()), - t.font(11.0), - t.text_dim, - ); - } - let mut new_pts = None; - if resp.double_clicked() - && let Some(q) = resp.interact_pointer_pos() - && let Some((i, dist)) = nearest(q) - && dist < 10.0 - && i != 0 - && i != pts.len() - 1 - { - let mut v = pts.clone(); - v.remove(i); - new_pts = Some(v); - } else if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - // pick the point under the press, not where the pointer is once the drag threshold passed - let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"})); - match nearest(q) { - Some((i, dist)) if dist < 10.0 => dragging = Some(i), - _ => { - let (x, y) = from_screen(q); - let mut v = pts.clone(); - let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); - v.insert(i, Point::new(x, y)); - dragging = Some(i); - new_pts = Some(v); - } - } - } else if resp.dragged() - && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) - { - let mut v = pts.clone(); - // ⌥-drag = fine adjustment, as in Lightroom: the point moves a tenth of the pointer's - // motion (relative to where it is, so pressing ⌥ mid-drag doesn't make it jump) - let fine = ui.input(|i| i.modifiers.alt); - let (mut x, y) = match v.get(i) { - Some(cur) if fine => { - let dd = ui.input(|i| i.pointer.delta()); - ((cur.x + (dd.x / r.width()) as f64 * 0.1).clamp(0.0, 1.0), (cur.y - (dd.y / r.height()) as f64 * 0.1).clamp(0.0, 1.0)) - } - _ => from_screen(q), - }; - if i < v.len() { - let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 }; - let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 }; - if i == 0 { - x = x.min(hi); - } else if i + 1 == v.len() { - x = x.max(lo); - } else if lo <= hi { - x = x.clamp(lo, hi); - } else { - // neighbours closer than the 0.02 spacing (two clicks side by side, or points - // from a file) leave no room: the point stays midway (`clamp` would panic: min above max) - x = (v[i - 1].x + v[i + 1].x) / 2.0; - } - v[i] = Point::new(x, y); - new_pts = Some(v); - } - } else if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - && nearest(q).is_none_or(|(_, dist)| dist >= 10.0) - { - let (x, y) = from_screen(q); - let mut v = pts.clone(); - let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); - v.insert(i, Point::new(x, y)); - new_pts = Some(v); - } - if let Some(v) = new_pts { - let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect(); - let _ = app.run("develop.curve", json!({"channel": ch, "points": arr})); - } - // the drag is over when egui says so, or when the pointer comes up without it saying so (a - // release outside the window would otherwise leave the interaction open, and every later edit a - // silent preview that Undo cannot take back) - if commit_interaction(app, ui, dragging.is_some(), resp.drag_stopped()) { - dragging = None; - } - ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); - curve_footer(app, ui, d); - control(app, ui, d, "curve.refineSaturation", true); - let _ = id; -} - -/// The row under the curve graph: point-curve presets and reset every curve. -fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - use lightcraft_engine::cmd::curves::{all_presets, matching_preset}; - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label)); - let current = matching_preset(&app.session, &d.curve); - let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name)); - let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin); - let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text); - egui::Popup::menu(&r).show(|ui| { - ui.set_min_width(180.0); - let presets = all_presets(&app.session); - let mut user_seen = false; - for p in &presets { - if !p.builtin && !user_seen { - user_seen = true; - ui.separator(); - } - let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)); - register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect); - if r.clicked() { - let _ = app.run("curve.applyPreset", json!({"name": p.name})); - ui.close(); - } - if !p.builtin { - crate::widgets::context_menu(&r, |ui| { - if ui.button(crate::i18n::tr("Delete Preset")).clicked() { - let _ = app.run("curve.deletePreset", json!({"name": p.name})); - ui.close(); - } - }); - } - } - ui.separator(); - let shaped = current.as_deref() != Some("Linear"); - let r = ui.add_enabled(shaped, egui::Button::new(crate::i18n::tr("Save Point Curve…"))); - register(ui.ctx(), "curvePresetMenu:save", r.rect); - if r.clicked() { - crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name"); - ui.close(); - } - if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { - let _ = app.run("file.importCurvePresets", json!({})); - ui.close(); - } - let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty(); - if ui.add_enabled(can_export, egui::Button::new(crate::i18n::tr("Export Presets…"))).clicked() { - let _ = app.run("file.exportCurvePresets", json!({})); - ui.close(); - } - if presets.iter().any(|p| !p.builtin) { - ui.label(RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); - } - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = text_button(ui, "curveReset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr( - "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)", - )); - if r.clicked() { - let _ = app.run("curve.reset", json!({"channel": "all"})); - } - }); - }); - }); -} - -/// Right-click menu of the curve graph. -fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { - let label = if ch == "parametric" { - crate::i18n::tr("Reset Parametric Curve").to_string() - } else { - crate::i18n::tr_format!("Reset {} Channel", crate::i18n::tr(channel_label(ch))) - }; - let r = ui.button(label); - register(ui.ctx(), "curveMenu:resetChannel", r.rect); - if r.clicked() { - let _ = app.run("curve.reset", json!({"channel": ch})); - ui.close(); - } - let r = ui.button(crate::i18n::tr("Reset All Curves")); - register(ui.ctx(), "curveMenu:resetAll", r.rect); - if r.clicked() { - let _ = app.run("curve.reset", json!({"channel": "all"})); - ui.close(); - } -} - -fn channel_label(ch: &str) -> &'static str { - match ch { - "red" => "Red", - "green" => "Green", - "blue" => "Blue", - _ => "RGB", - } -} - -/// Toggle for a targeted-adjustment tool (`tool` = `tat:`). -fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { - let active = app.ui.tool == tool; - let id = tool.replace(':', "-"); - if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { - set_tat(app, ui.ctx(), tool, active); - } -} - -/// Turn targeted-adjustment tool `tool` off (`active`) or on. Turning it on says how to use and -/// leave it: while it is on, dragging on the photo adjusts instead of zooming or panning, which is -/// easy to miss when the button looks like a tab. -fn set_tat(app: &mut LightcraftApp, ctx: &egui::Context, tool: &str, active: bool) { - if active { - app.ui.tool.clear(); - } else { - app.ui.tool = tool.to_string(); - app.toast(ctx, crate::i18n::tr("Targeted adjustment on: drag up or down on the photo · Esc to stop")); - } -} - -// ------------------------------------------------------------------------------ colour mixer - -fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); - let bands = lightcraft_develop::MIXER_BANDS; - let sel = bands.iter().position(|b| *b == app.ui.mixer_mode).unwrap_or(0); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 5.0; - for (i, b) in bands.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr(b)); - register(ui.ctx(), format!("mixerBand:{b}"), r); - ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i])); - if i == sel { - ui.painter().circle_stroke(r.center(), 10.5, Stroke::new(1.5, t.text)); - ui.painter().circle_filled(r.center(), 2.0, Color32::WHITE); - } - if resp.clicked() { - app.ui.mixer_mode = b.to_string(); - } - } - }); - }); - // targeted adjustment: pick the attribute, then drag on the photo - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); - paint(ui.painter(), r, Icon::Target, t.icon); - let items: &[(&str, &str)] = - if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] }; - for (k, label) in items { - let tool = format!("tat:{k}"); - let active = app.ui.tool == tool; - if text_button(ui, &format!("tatMixer-{k}"), crate::i18n::tr(label), active) - .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) - .clicked() - { - set_tat(app, ui.ctx(), &tool, active); - } - } - }); - }); - let b = bands[sel]; - if crate::is_bw(d) { - ui.horizontal(|ui| { - ui.add_space(24.0); - if crate::widgets::text_button(ui, "bwAuto", crate::i18n::tr("Auto"), false) - .on_hover_text(crate::i18n::tr("Set the mix from the photo's colours")) - .clicked() - { - let _ = app.run("develop.autoBwMix", json!({})); - } - }); - control(app, ui, d, &format!("bw.{b}"), true); - } else { - for k in ["hue", "sat", "lum"] { - control(app, ui, d, &format!("mixer.{b}.{k}"), true); - } - } -} - -// ------------------------------------------------------------------------------ point color - -/// Display colour of an OkLCh sample (lightness, chroma, hue in degrees). -fn oklch_color(l: f64, c: f64, h_deg: f64) -> Color32 { - use lightcraft_color::perceptual::{lch_to_lab, oklab_to_2020}; - let lin = oklab_to_2020(lch_to_lab([l as f32, c as f32, (h_deg as f32).to_radians()])); - let s = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).apply_f32(lin); - let e = s.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8); - Color32::from_rgb(e[0], e[1], e[2]) -} - -/// Point Color: swatches of the samples (+ the eyedropper), the selected sample's shifts and range, -/// and "Visualize range". -fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); - let n = d.point_colors.len(); - if app.ui.point_color >= n && n > 0 { - app.ui.point_color = n - 1; - } - let sel = app.ui.point_color; - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 5.0; - let active = app.ui.tool == "pointColor"; - let full = n >= lightcraft_develop::MAX_POINT_COLORS; - if crate::widgets::icon_button( - ui, - "pointColorPicker", - Icon::Picker, - vec2(26.0, 26.0), - active, - !full, - crate::i18n::tr("Sample a colour on the photo"), - ) - .clicked() - { - app.ui.tool = if active { String::new() } else { "pointColor".into() }; - } - for (i, p) in d.point_colors.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Point Color"), i + 1)); - register(ui.ctx(), format!("pointColor:{i}"), r); - ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); - if i == sel { - ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); - } - if resp.clicked() { - app.ui.point_color = i; - } - } - }); - }); - if n == 0 { - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Pick a colour on the photo with the eyedropper.")).size(12.0).color(t.text_dim)); - }); - return; - } - for k in ["hueShift", "satShift", "lumShift", "variance"] { - control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); - } - sub_title(ui, crate::i18n::tr("Range")); - for k in ["range", "hueRange", "satRange", "lumRange"] { - control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); - } - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| { - ui.horizontal(|ui| { - let mut v = app.ui.point_color_visualize; - if ui.checkbox(&mut v, crate::i18n::tr("Visualize range")).changed() { - app.ui.point_color_visualize = v; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "pointColorDelete", crate::i18n::tr("Delete"), false).clicked() { - let _ = app.run("pointColor.delete", json!({"index": sel})); - } - }); - }); - }); -} - -// ------------------------------------------------------------------------------ HDR - -/// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit. -/// The Light panel's HDR settings while HDR editing is on (the switch itself is the HDR button -/// next to B&W): Visualize HDR range and the headroom limit. -fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - if !d.hdr.enabled { - return; - } - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { - let mut v = app.ui.hdr_visualize; - let r = ui.checkbox(&mut v, "Visualize HDR"); - register(ui.ctx(), "check:hdrVisualize", r.rect); - if r.on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it").changed() { - app.ui.hdr_visualize = v; - } - }); - control(app, ui, d, "hdr.maxEv", true); -} - -// ------------------------------------------------------------------------------ colour grading - -/// The colour grading tabs: key, tooltip, icon. -const GRADING_TABS: [(&str, &str, Icon); 4] = [ - ("3way", "3-Way", Icon::GradeAll), - ("shadows", "Shadows", Icon::GradeShadows), - ("midtones", "Midtones", Icon::GradeMidtones), - ("highlights", "Highlights", Icon::GradeHighlights), -]; - -/// The wheels, in the order of `UiState::grading_hidden`. -const GRADING_WHEELS: [(&str, &str); 3] = [("shadows", "Shadows"), ("midtones", "Midtones"), ("highlights", "Highlights")]; - -fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId) { - let mode = GRADING_TABS.iter().find(|(k, ..)| *k == app.ui.grading_mode).map_or("3way", |(k, ..)| *k); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (key, tip, icon) in GRADING_TABS { - if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)) - .clicked() - { - app.ui.grading_mode = key.to_string(); - } - } - }); - }); - let w = ui.available_width(); - if mode == "3way" { - // three wheels of one size: midtones on top, shadows and highlights below - let rad = 40.0; - let (area, _) = ui.allocate_exact_size(vec2(w, 276.0), Sense::hover()); - let at = [ - pos2(area.left() + w * 0.28, area.top() + 196.0), - pos2(area.center().x, area.top() + 70.0), - pos2(area.left() + w * 0.72, area.top() + 196.0), - ]; - for (i, c) in at.into_iter().enumerate() { - grading_wheel(app, ui, d, photo, i, c, rad, true); - } - for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum"] { - control(app, ui, d, c, true); - } - } else { - let i = GRADING_WHEELS.iter().position(|(k, _)| *k == mode).unwrap_or(1); - let rad = (w * 0.3).clamp(40.0, 80.0); - let (area, _) = ui.allocate_exact_size(vec2(w, rad * 2.0 + 40.0), Sense::hover()); - grading_wheel(app, ui, d, photo, i, pos2(area.center().x, area.top() + rad + 10.0), rad, false); - for part in ["hue", "sat", "lum"] { - control(app, ui, d, &format!("grading.{mode}.{part}"), true); - } - } - for c in ["grading.blending", "grading.balance"] { - control(app, ui, d, c, true); - } -} - -/// One colour grading wheel (`i` into [`GRADING_WHEELS`]) centred on `c`: drag to pick hue and -/// saturation, double-click to reset, and an eye at its bottom-left corner that hides its effect -/// on the photo for viewing. `readout`: show hue and saturation centred above the wheel while -/// hovered (3-Way); otherwise the wheel is the large one and gets saturation guide rings. -#[allow(clippy::too_many_arguments)] -fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId, i: usize, c: Pos2, rad: f32, readout: bool) { - let t = Tokens::get(ui.ctx()); - let Some(&(key, label)) = GRADING_WHEELS.get(i) else { return }; - let wh = match key { - "shadows" => d.grading.shadows, - "midtones" => d.grading.midtones, - _ => d.grading.highlights, - }; - let hidden = app.ui.grading_hidden_for(photo).get(i).copied().unwrap_or(false); - let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); - // the corners (eye, readout) count as the wheel for hovering - let zone = r.expand(10.0); - let hovering = ui.rect_contains_pointer(zone); - let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); - crate::widgets::announce(&resp, format!("{} {key}", crate::i18n::tr("Color wheel"))); - let resp = resp.on_hover_text(crate::i18n::tr( - "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.", - )); - register(ui.ctx(), format!("wheel:{key}"), r); - paint_wheel(ui.painter(), c, rad); - if hidden { - ui.painter().circle_filled(c, rad, Color32::from_black_alpha(150)); - } - // the large single wheel: dashed rings at 25 / 50 / 75 % saturation to judge strength by eye - if !readout { - for f in [0.25f32, 0.5, 0.75] { - let ring_rad = rad * f; - let n = (ring_rad * std::f32::consts::TAU / 2.0).max(8.0) as usize; - let ring: Vec = (0..=n).map(|k| c + egui::Vec2::angled(k as f32 / n as f32 * std::f32::consts::TAU) * ring_rad).collect(); - ui.painter().extend(egui::Shape::dashed_line(&ring, Stroke::new(0.8, Color32::from_white_alpha(45)), 4.0, 3.5)); - } - } - let a = (wh.hue as f32).to_radians(); - let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); - // Modifiers (as in Lightroom): Shift keeps the hue, Cmd/Ctrl keeps the saturation, Alt moves - // the puck a tenth as far as the pointer. Pressing or releasing one mid-drag re-grabs the - // puck as it is at that moment. - let grab_id = egui::Id::new(("wheelGrab", key)); - let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); - let pointer = wheel_pointer(ui, &resp); - let grab = match pointer { - Some(q) if resp.dragged() || resp.clicked() => { - let held = ui.data(|m| m.get_temp::(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); - Some(held.unwrap_or_else(|| { - // a fine drag counts the pointer's travel from the press, not from the frame - // egui first called it a drag - let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(q) } else { q }; - let g = WheelGrab::new(mods, wh.hue as f32, wh.sat as f32, origin - c, rad); - ui.data_mut(|m| m.insert_temp(grab_id, g)); - g - })) - } - _ => { - ui.data_mut(|m| m.remove::(grab_id)); - None - } - }; - // while a constraint is held: the guide for the one move left (the whole spoke for the hue, - // the circle for the saturation) - let guide = Stroke::new(1.5, Color32::from_white_alpha(170)); - match grab.and_then(|g| g.lock()) { - Some(WheelLock::Hue(h)) => { - let a = h.to_radians(); - ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], guide); - } - Some(WheelLock::Sat(s)) => { - ui.painter().circle_stroke(c, rad * s / 100.0, guide); - } - None => {} - } - // the neutral centre (issue #559), under the point - ui.painter().circle_filled(c, 2.0, Color32::WHITE); - ui.painter().circle_filled(dot, 5.0, if hidden { Color32::from_gray(150) } else { Color32::WHITE }); - ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); - ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); - if resp.drag_started() { - let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); - } - if (resp.dragged() || resp.clicked()) - && let Some(q) = pointer - { - let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); - } - if resp.drag_stopped() || resp.clicked() { - let _ = app.run("develop.endInteraction", json!({})); - } - // released, or Esc (egui ends a drag on it) - if resp.drag_stopped() - && let Some(held) = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::new(WHEEL_CAPTURE))) - && !held.dropped - { - give_pointer_back(ui.ctx(), held.origin); - } - if resp.double_clicked() { - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); - } - // hue and saturation readout: centred above the wheel, shown like the eye (no fade) - if readout && (hovering || resp.dragged()) { - // set a little below the gap's midpoint: the eye reads it as centred there - ui.painter().text( - pos2(c.x, r.top() - 13.0), - Align2::CENTER_CENTER, - format!("H {}° S {}", wh.hue.round() as i64, wh.sat.round() as i64), - t.font(11.0), - t.text_label, - ); - } - // the eye: on top of the wheel, shown while hovering or while the wheel is hidden - if hovering || hidden { - let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); - let er = ui.interact(eye, egui::Id::new(("wheelEye", key)), Sense::click()); - crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); - register(ui.ctx(), format!("wheelEye:{key}"), eye); - paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); - let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); - if er.clicked() { - app.ui.set_grading_hidden(photo, i, !hidden); - } - } -} - -/// A wheel's modifier state. Shift keeps the hue, Cmd/Ctrl the saturation (Shift wins when both -/// are down: keeping both would freeze the puck), Alt fine-adjusts. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) struct WheelMods { - pub keep_hue: bool, - pub keep_sat: bool, - pub fine: bool, -} - -impl WheelMods { - pub(crate) fn from_modifiers(m: egui::Modifiers) -> Self { - Self { keep_hue: m.shift, keep_sat: m.command && !m.shift, fine: m.alt } - } -} - -/// What a constraint holds still while the pointer moves. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) enum WheelLock { - /// Keep this hue (degrees): only the saturation changes, along the spoke at that hue. - Hue(f32), - /// Keep this saturation (0..100): only the hue changes, round the circle at that radius. - Sat(f32), -} - -/// Pointer travel to puck travel with Alt held, as for a slider's fine adjust. -const WHEEL_FINE: f32 = 0.1; - -/// The puck as it was when the pointer grabbed it (or a modifier last changed): the hue and -/// saturation a constraint keeps, and the origin a fine adjust measures the pointer's travel from. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct WheelGrab { - pub mods: WheelMods, - /// The puck's hue (degrees) and saturation (0..100) at the grab. - hue: f32, - sat: f32, - /// The pointer at the grab, as an offset from the wheel's centre. - origin: egui::Vec2, -} - -impl WheelGrab { - /// Grab a puck at `hue` / `sat` with the pointer `origin` from the centre of a wheel of - /// radius `rad`. The kept hue is the puck's own, never the angle the pointer happens to be - /// at. A puck at the centre (saturation under 1) has no hue yet, so there it is the angle of - /// the pointer at the grab; the same rule covers a non-finite hue. - pub(crate) fn new(mods: WheelMods, hue: f32, sat: f32, origin: egui::Vec2, rad: f32) -> Self { - let sat = if sat.is_finite() { sat.clamp(0.0, 100.0) } else { 0.0 }; - let hue = if hue.is_finite() && sat >= 1.0 { hue.rem_euclid(360.0) } else { wheel_pick(origin, rad, None).0 }; - Self { mods, hue, sat, origin } - } - - /// The constraint this grab holds, if any. - pub(crate) fn lock(&self) -> Option { - if self.mods.keep_hue { - Some(WheelLock::Hue(self.hue)) - } else if self.mods.keep_sat { - Some(WheelLock::Sat(self.sat)) - } else { - None - } - } - - /// The new hue and saturation for the pointer at offset `v` from the centre. - pub(crate) fn pick(&self, v: egui::Vec2, rad: f32) -> (f32, f32) { - let v = if self.mods.fine { - // the puck where it was, moved a tenth of the pointer's travel since the grab - let a = self.hue.to_radians(); - let puck = vec2(a.cos(), -a.sin()) * rad.max(1.0) * (self.sat / 100.0); - puck + (v - self.origin) * WHEEL_FINE - } else { - v - }; - wheel_pick(v, rad, self.lock()) - } -} - -/// A wheel drag holds the pointer, as in Lightroom (issue #1899): it is hidden for the drag, the -/// puck follows the mouse's own travel, and on release the pointer is back where the button went -/// down, so a long (Alt) drag doesn't leave it far from the wheels. On macOS the pointer is locked -/// in place for the drag; elsewhere it moves unseen and is put back. The browser can't move it -/// (eframe's web host ignores the command), so there it only shows again where the drag ended. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -struct WheelCapture { - /// Where the button went down: the pointer goes back there. - origin: Pos2, - /// Where the pointer would be had it been free: where the drag began plus the travel since. - pos: Pos2, - /// Let go early (the window lost focus): the rest of this drag leaves the wheel alone. - dropped: bool, - /// The pass that last drew the dragged wheel. - pass: u64, -} - -/// One wheel is dragged at a time, so one capture for them all. -const WHEEL_CAPTURE: &str = "wheelCapture"; - -/// The mouse's travel this frame: the pointer's, or, while the pointer stands still (locked, or -/// against a screen edge), the raw motion the host reports. -fn wheel_travel(delta: egui::Vec2, motion: Option) -> egui::Vec2 { - if delta != egui::Vec2::ZERO { - return delta; - } - motion.filter(|m| m.x.is_finite() && m.y.is_finite()).unwrap_or(egui::Vec2::ZERO) -} - -/// Where the wheel `resp` reads the pointer this frame: where it clicked, or for a drag the held -/// pointer ([`WheelCapture`]), `None` once a lost focus let go of it (Esc ends the drag itself: -/// egui stops a drag on Esc). -fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option { - let q = resp.interact_pointer_pos()?; - if !resp.dragged() { - return Some(q); - } - let ctx = ui.ctx(); - let id = egui::Id::new(WHEEL_CAPTURE); - let held = ctx.data(|m| m.get_temp::(id)).filter(|_| !resp.drag_started()); - let mut cap = held.unwrap_or_else(|| { - if cfg!(target_os = "macos") { - ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked)); - } - WheelCapture { origin: ui.input(|i| i.pointer.press_origin()).unwrap_or(q), pos: q, ..Default::default() } - }); - cap.pass = ctx.cumulative_pass_nr(); - if !cap.dropped { - let (travel, unfocused) = ui.input(|i| { - // the event, not `focused`: a click that activates the window may land before its focus - let unfocused = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))); - (wheel_travel(i.pointer.delta(), i.pointer.motion()), unfocused) - }); - if !resp.drag_started() { - cap.pos += travel; - } - if unfocused { - give_pointer_back(ctx, cap.origin); - cap.dropped = true; - } else { - ctx.set_cursor_icon(egui::CursorIcon::None); - } - } - ctx.data_mut(|m| m.insert_temp(id, cap)); - (!cap.dropped).then_some(cap.pos) -} - -/// Unlock and show the pointer a wheel drag held, back at `origin`. -fn give_pointer_back(ctx: &egui::Context, origin: Pos2) { - if cfg!(target_os = "macos") { - ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)); - } - if cfg!(not(target_arch = "wasm32")) { - ctx.send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); - } -} - -/// Once a frame, after the panels: a held pointer stays hidden over whatever set its own cursor, -/// and is given back if no wheel drew this pass (the flyout closed or the view changed under the -/// drag), so it is never left locked or hidden. -pub(crate) fn wheel_pointer_frame_end(ctx: &egui::Context) { - let id = egui::Id::new(WHEEL_CAPTURE); - let Some(cap) = ctx.data(|m| m.get_temp::(id)) else { return }; - if cap.pass != ctx.cumulative_pass_nr() { - ctx.data_mut(|m| m.remove::(id)); - if !cap.dropped { - give_pointer_back(ctx, cap.origin); - } - } else if !cap.dropped { - ctx.set_cursor_icon(egui::CursorIcon::None); - } -} - -/// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. -/// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the -/// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked -/// value, else 0). -pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { - let rad = rad.max(1.0); - let v = if v.x.is_finite() && v.y.is_finite() { v } else { egui::Vec2::ZERO }; - let angle = || ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; - match lock { - Some(WheelLock::Hue(h)) => { - let h = if h.is_finite() { h } else { 0.0 }; - let a = h.to_radians(); - let along = v.x * a.cos() - v.y * a.sin(); - (h, (along / rad * 100.0).clamp(0.0, 100.0)) - } - Some(WheelLock::Sat(s)) => (angle(), if s.is_finite() { s.clamp(0.0, 100.0) } else { 0.0 }), - None => (angle(), (v.length() / rad * 100.0).min(100.0)), - } -} - -fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { - p.add(wheel_mesh(c, rad)); - p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); -} - -/// The picture of a grading wheel: a hue disc whose rim is fully saturated display sRGB at full -/// brightness, fading to a neutral grey at the centre. It is built from concentric rings so the -/// saturation ramps evenly with the radius instead of one fan of triangles blending each hue -/// straight into grey (which made the whole disc look muddy, issue #77). Only the drawing: -/// what a position means for the edit is [`wheel_pick`]. -pub(crate) fn wheel_mesh(c: Pos2, rad: f32) -> Mesh { - const SEGMENTS: u32 = 72; - const RINGS: u32 = 12; - /// Brightness (HSV value) at the centre; the rim is 1.0. - const CENTRE_V: f32 = 0.6; - let colour = |hue_deg: f32, t: f32| { - let rgb = lightcraft_color::perceptual::hsv_to_rgb(hue_deg, t, CENTRE_V + (1.0 - CENTRE_V) * t); - let u = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(u(rgb[0]), u(rgb[1]), u(rgb[2])) - }; - let mut mesh = Mesh::default(); - mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: colour(0.0, 0.0) }); - // ring k (1..=RINGS) holds SEGMENTS + 1 vertices starting at index 1 + (k - 1) * (SEGMENTS + 1) - let row = SEGMENTS + 1; - for k in 1..=RINGS { - let t = k as f32 / RINGS as f32; - for i in 0..=SEGMENTS { - let a = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; - mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad * t, uv: Pos2::ZERO, color: colour(a.to_degrees(), t) }); - } - } - for i in 0..SEGMENTS { - mesh.indices.extend([0, 1 + i, 2 + i]); - } - for k in 1..RINGS { - let inner = 1 + (k - 1) * row; - let outer = inner + row; - for i in 0..SEGMENTS { - mesh.indices.extend([inner + i, outer + i, outer + i + 1, inner + i, outer + i + 1, inner + i + 1]); - } - } - mesh -} - -/// Quick Develop (grid with several photos selected): relative steps applied to every selected -/// photo from its own value. -fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr_format!("Quick Develop · {n} photos", n = n)).color(t.text_label).size(12.5)); - ui.add_space(4.0); - let rows: [(&str, &str, f64, f64); 9] = [ - ("Exposure", "light.exposure", 1.0 / 3.0, 1.0), - ("Contrast", "light.contrast", 5.0, 20.0), - ("Highlights", "light.highlights", 5.0, 20.0), - ("Shadows", "light.shadows", 5.0, 20.0), - ("Whites", "light.whites", 5.0, 20.0), - ("Blacks", "light.blacks", 5.0, 20.0), - ("Clarity", "effects.clarity", 5.0, 20.0), - ("Vibrance", "color.vibrance", 5.0, 20.0), - ("Temp", "wb.temp", 100.0, 500.0), - ]; - egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { - for (label, ctl, small, big) in rows { - ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); - // Keep the existing automation ids while drawing all step arrows as vectors. - for (txt, icon, d) in [ - ("◀◀", Icon::ChevronDoubleLeft, -big), - ("◀", Icon::ChevronLeft, -small), - ("▶", Icon::ChevronRight, small), - ("▶▶", Icon::ChevronDoubleRight, big), - ] { - let r = crate::widgets::arrow_button(ui, "", icon, false); - r.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::Button, - ui.is_enabled(), - crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)), - ) - }); - crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect); - if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label))) - .clicked() - { - let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d})); - } - } - ui.end_row(); - } - }); - }); -} - -/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can -/// decode, whose drive was offline when the library opened) in the background; a toast says what -/// came of it once it's done (`crate::reload`). -pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { - match app.run("photo.reload", json!({"ids": [id.0]})) { - Err(e) => app.toast_error(ctx, e), - Ok(r) if r["background"] == true => {} - // nothing to read in the background (a photo without a file): answered in place - Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { - app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) - } - Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), - } -} - -/// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). -fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { - let base = crate::i18n::tr("Reset all edits"); - match crate::shortcuts::shortcut_of(keymap, "develop.reset") { - Some(sc) => format!("{base} ({})", crate::menubar::shortcut_text(sc, mac)), - None => base.to_string(), - } -} - -#[cfg(test)] -mod curve_histogram_tests { - use super::*; - - #[test] - fn curve_backdrop_tracks_luma_and_selected_channel() { - let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] }); - let h = lightcraft_raster::Histogram::of_srgb8(&img); - let red = curve_histogram_profile(&h, "red"); - let green = curve_histogram_profile(&h, "green"); - let luma = curve_histogram_profile(&h, "master"); - assert!(red[32] > red[150] && red[224] > red[150]); - assert!(green[100] > green[32] && green[100] > green[224]); - assert!(luma.iter().any(|v| *v > 0.0)); - } - - #[test] - fn hdr_backdrop_uses_only_the_sdr_bins_and_never_overflows() { - let h = lightcraft_raster::Histogram::of_hdr(&[[0.1, 0.1, 0.1], [1.0, 1.0, 1.0], [8.0, 8.0, 8.0]], 4.0); - let values = curve_histogram_profile(&h, "parametric"); - assert!(values.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); - assert!(values.iter().any(|v| *v > 0.0)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { - let ctx = egui::Context::default(); - let id = PhotoId(7); - let sdr = Histogram::of_hdr(&[], 5.0); - let sdr = Histogram { hdr: None, ..sdr }; - let hdr = |v: f32| Histogram::of_hdr(&[[v, v, v]], 5.0); - // nothing kept yet: what arrives - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(sdr.clone())); - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(2.0))), Some(hdr(2.0))); - // a cached thumbnail's 8-bit histogram (or none) doesn't swap the panel to SDR - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(2.0))); - assert_eq!(steady_hdr_histogram(&ctx, id, true, None), Some(hdr(2.0))); - // the next HDR render replaces it: the histogram follows the slider - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(4.0))), Some(hdr(4.0))); - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0))); - // HDR turned off: the SDR histogram at once; other photos keep their own - assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); - assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); - } - - /// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows. - #[test] - fn reset_tooltip_names_the_shortcut_of_this_platform() { - let keymap = crate::shortcuts::Keymap::new(); - assert_eq!(reset_tip(&keymap, false), "Reset all edits (Ctrl+Shift+R)"); - assert_eq!(reset_tip(&keymap, true), "Reset all edits (⌘⇧R)"); - let rebound: crate::shortcuts::Keymap = [("develop.reset".to_string(), "Cmd+Alt+R".to_string())].into(); - assert_eq!(reset_tip(&rebound, false), "Reset all edits (Ctrl+Alt+R)"); - let unbound: crate::shortcuts::Keymap = [("develop.reset".to_string(), String::new())].into(); - assert_eq!(reset_tip(&unbound, false), "Reset all edits"); - } -} diff --git a/crates/ui-egui/src/panels/edit/curve.rs b/crates/ui-egui/src/panels/edit/curve.rs new file mode 100644 index 000000000..856211e66 --- /dev/null +++ b/crates/ui-egui/src/panels/edit/curve.rs @@ -0,0 +1,534 @@ +use super::histogram::preview_histogram; +use super::sliders::control; +use super::*; + +// ------------------------------------------------------------------------------ tone curve + +/// A 256-point, peak-normalized display profile of the photo histogram. +/// For HDR, only the SDR portion maps to a curve's input (0..1). +fn curve_histogram_profile(h: &lightcraft_raster::Histogram, channel: &str) -> [f32; 256] { + let source = match channel { + "red" => &h.r, + "green" => &h.g, + "blue" => &h.b, + _ => &h.luma, + }; + let bins = h.hdr.map_or(256, |hdr| hdr.from.clamp(2, 256)); + let samples: [f32; 256] = std::array::from_fn(|i| { + let j = i * (bins - 1) / 255; + let start = j.saturating_sub(2); + let end = (j + 3).min(bins); + let sum: f32 = (start..end).map(|k| source.get(k).copied().unwrap_or(0) as f32).sum(); + sum / (end - start) as f32 + }); + let peak = samples.iter().copied().fold(1.0_f32, f32::max); + samples.map(|v| (v / peak).sqrt()) +} + +/// Show the currently rendered photo's tonal distribution behind the editable +/// curve, without requesting another render or blocking pointer interactions. +fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histogram, channel: &str) { + let profile = curve_histogram_profile(h, channel); + let color = match channel { + "red" => hex("#dd3333"), + "green" => hex("#33bb55"), + "blue" => hex("#3377ee"), + _ => Color32::from_gray(130), + } + .gamma_multiply(0.27); + let mut mesh = Mesh::default(); + for (i, height) in profile.into_iter().enumerate() { + let x = r.left() + r.width() * i as f32 / 255.0; + let top = pos2(x, r.bottom() - r.height() * height); + mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color }); + mesh.vertices.push(Vertex { pos: pos2(x, r.bottom()), uv: Pos2::ZERO, color }); + if i > 0 { + let k = i as u32 * 2; + mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); + } + } + p.add(mesh); +} + +/// Segments the graph's curve line is drawn with. +pub(crate) const CURVE_SAMPLES: i32 = 96; + +/// Close a graph drag's `develop` interaction, as one undo step. +/// +/// It is closed when the drag ends *or* the pointer comes up without egui reporting `drag_stopped` +/// for this widget (released outside the window, over a popup, or on a frame the widget does not +/// run). A dangling interaction would make every later edit a silent preview — Undo would then +/// appear to do nothing at all. Returns whether the drag is over, so the caller clears its state. +fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, stopped: bool) -> bool { + let released = ui.input(|i| i.pointer.any_released()) || !ui.input(|i| i.pointer.primary_down()); + if !dragging || !(stopped || released) { + return false; + } + let _ = app.run("develop.endInteraction", json!({})); + true +} + +/// How close (screen points) to the curve a press counts as grabbing it. +const PARAM_GRAB: f32 = 14.0; + +/// The parametric curve's own drag: the graph shows the curve and nothing else, and dragging the +/// line bends the region the pointer is over, with that region's slider following on the same frame. +/// +/// The whole gesture runs inside one `develop` interaction, so it is one undo step (Ctrl+Z takes the +/// gesture back, not the last frame of it), and every write goes through `develop.set`, so the +/// slider, the curve and what is drawn are the same state rather than three things kept in step. +fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { + if r.width() <= 1.0 || r.height() <= 1.0 { + return; + } + let t = Tokens::get(ui.ctx()); + let model = ParametricCurve::new(&d.curve); + let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); + let from_screen_x = |q: Pos2| ((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; + // the graph x of a point, and how far it is from the drawn curve (in screen points) + let near_curve = |q: Pos2| { + let x = from_screen_x(q); + to_screen(x, model.at(x)).distance(q) + }; + let drag_id = egui::Id::unique("curve-param-drag"); + let cursor_id = egui::Id::unique("curve-param-cursor"); + // (region, x, initial_amount, start_y): the region being dragged, the graph x the drag is anchored + // at, the region's starting amount, and the starting screen Y position. + let mut dragged: Option<(usize, f64, f64, f32)> = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); + let hovered = dragged.is_none() && resp.hover_pos().is_some_and(|q| near_curve(q) <= PARAM_GRAB); + let mut set: Option<(&'static str, f64)> = None; + // the press, not where the pointer is once the drag threshold has passed: a press slightly off + // the line is still a grab, and the pointer has usually left that spot by the time it starts + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some(q) = press + && near_curve(q) <= PARAM_GRAB + { + let x = from_screen_x(q); + let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"})); + let region = model.region_at(x); + let initial_amount = model.amounts.get(region).copied().unwrap_or(0.0); + let start_pos = to_screen(x, model.at(x)); + dragged = Some((region, x, initial_amount, start_pos.y)); + ui.data_mut(|dd| dd.insert_temp(cursor_id, start_pos)); + } + if let Some((region, x, initial_amount, start_y)) = dragged + && resp.dragged() + { + // Follow the pointer's motion from where the curve was grabbed, so the press itself moves + // nothing and the line tracks the hand. ⌥ = a tenth of the motion, as the point curve does. + let alt = ui.input(|i| i.modifiers.alt); + let dd = ui.input(|i| i.pointer.delta()); + let step = if alt { vec2(dd.x * 0.1, dd.y * 0.1) } else { vec2(dd.x, dd.y) }; + let cursor = ui.data(|d| d.get_temp::(cursor_id)).unwrap_or(pos2(0.0, start_y)) + step; + ui.data_mut(|d| d.insert_temp(cursor_id, cursor)); + let delta_y = start_y - cursor.y; + let delta_level = (delta_y / r.height()) as f64; + let per_unit = model.level_per_unit(region, x); + let new_amount = + if per_unit.abs() > f64::EPSILON { (initial_amount + delta_level / per_unit).clamp(-100.0, 100.0).round() } else { initial_amount }; + if let Some(&id) = PARAM_AMOUNTS.get(region) { + set = Some((id, new_amount)); + } + } + if let Some((id, v)) = set { + let _ = app.run("develop.set", json!({"control": id, "value": v})); + } + let dropped = resp.drag_stopped(); + if commit_interaction(app, ui, dragged.is_some(), dropped) { + dragged = None; + ui.data_mut(|d| { + d.remove::(cursor_id); + d.remove::>(drag_id); + }); + } + ui.data_mut(|dd| dd.insert_temp(drag_id, dragged)); + if dragged.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + // the region under the pointer (or the one in hand), named as its slider below reads it + let shown = dragged.map(|(region, ..)| region).or_else(|| hovered.then(|| resp.hover_pos()).flatten().map(|q| model.region_at(from_screen_x(q)))); + if let Some(region) = shown + && let Some(&id) = PARAM_AMOUNTS.get(region) + && let Some(v) = set.and_then(|(s_id, v)| (s_id == id).then_some(v)).or_else(|| controls::get(d, id)) + { + let name = controls::find(id).map_or("", |s| s.label); + ui.painter().text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, format!("{name} {v:.0}"), t.font(11.0), t.text_dim); + } +} + +pub(super) fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + // channel selector + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + for (ch, c) in [ + ("parametric", Color32::from_gray(200)), + ("master", Color32::WHITE), + ("red", hex("#dd3333")), + ("green", hex("#33bb55")), + ("blue", hex("#3377ee")), + ] { + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {ch}", crate::i18n::tr("Curve channel"))); + register(ui, format!("curveChannel:{ch}"), r); + let sel = app.ui.curve_channel == ch; + if ch == "parametric" { + paint(ui.painter(), r.shrink(4.0), Icon::Curve, if sel { t.text } else { t.icon }); + } else { + ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(2.0, c)); + if sel { + ui.painter().circle_filled(r.center(), 3.5, c); + } + } + if sel { + ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text)); + } + let name = match ch { + "parametric" => "Parametric curve", + "master" => "Point curve", + "red" => "Red channel", + "green" => "Green channel", + _ => "Blue channel", + }; + let resp = resp.on_hover_text(crate::i18n::tr_format!("{name} — double-click to reset it", name = name)); + if resp.double_clicked() { + app.ui.curve_channel = ch.into(); + let _ = app.run("curve.reset", json!({"channel": ch})); + } else if resp.clicked() { + app.ui.curve_channel = ch.into(); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there"); + }); + }); + }); + let ch = app.ui.curve_channel.clone(); + let w = ui.available_width(); + let side = (w - 48.0).max(80.0); + let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover()); + let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); + let resp = ui.interact(r, egui::Id::unique(("curve", &ch)), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); + register(ui, "curve", r); + crate::widgets::context_menu(&resp, |ui| curve_reset_menu(app, ui, &ch)); + let p = ui.painter(); + p.rect_filled(r, 2.0, t.canvas); + if let Some(hist) = preview_histogram(app, id) { + draw_curve_histogram(p, r, &hist, &ch); + } + for i in 1..4 { + let f = i as f32 / 4.0; + p.line_segment( + [pos2(r.left() + r.width() * f, r.top()), pos2(r.left() + r.width() * f, r.bottom())], + Stroke::new(1.0, Color32::from_gray(52)), + ); + p.line_segment( + [pos2(r.left(), r.top() + r.height() * f), pos2(r.right(), r.top() + r.height() * f)], + Stroke::new(1.0, Color32::from_gray(52)), + ); + } + p.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.0, Color32::from_gray(70))); + let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); + let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64); + if ch == "parametric" { + // The Effective curve: the model the renderer builds from the four region sliders, sampled + // here and rebuilt every frame. The graph holds no state of its own, so a slider moved by + // hand, a Reset or an Undo redraws it from the settings in the same frame it changes. + let c = &d.curve; + let model = ParametricCurve::new(c); + let pts: Vec = + (0..=CURVE_SAMPLES).map(|i| to_screen(i as f64 / CURVE_SAMPLES as f64, model.at(i as f64 / CURVE_SAMPLES as f64))).collect(); + p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220)))); + param_drag(app, ui, r, &resp, d); + curve_footer(app, ui, d); + for &c in PARAM_AMOUNTS.iter().rev() { + control(app, ui, d, c, true); + } + control(app, ui, d, "curve.refineSaturation", true); + return; + } + let pts_of = |d: &DevelopSettings| -> Vec { + let v = match ch.as_str() { + "red" => &d.curve.red, + "green" => &d.curve.green, + "blue" => &d.curve.blue, + _ => &d.curve.master, + }; + // in the order the curve is drawn (points from a sidecar, a preset or an agent can come + // unsorted or not finite): the drag below keeps each point between its neighbours + let mut v: Vec = v.iter().copied().filter(|q| q.x.is_finite() && q.y.is_finite()).collect(); + v.sort_by(|a, b| a.x.total_cmp(&b.x)); + if v.len() < 2 { vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)] } else { v } + }; + let pts = pts_of(d); + let curve = lightcraft_color::spline::MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::>()); + let color = match ch.as_str() { + "red" => hex("#dd3333"), + "green" => hex("#33bb55"), + "blue" => hex("#3377ee"), + _ => Color32::from_gray(225), + }; + let line: Vec = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect(); + p.add(egui::Shape::line(line, Stroke::new(2.0, color))); + // interaction: drag a point (both axes, between its neighbours; ⌥ = fine), drag empty space to add and + // drag a new point, click empty space to add, double-click a point to delete it + let drag_id = egui::Id::unique(("curve-drag", &ch)); + // the point being dragged (stored as `Option` so a stale value can be cleared) + let mut dragging: Option = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); + let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1)); + let hovered = resp.hover_pos().and_then(nearest).filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i); + if dragging.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if hovered.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + for (i, q) in pts.iter().enumerate() { + let c = to_screen(q.x, q.y); + if dragging == Some(i) || (dragging.is_none() && hovered == Some(i)) { + p.circle_filled(c, 5.5, color); + p.circle_stroke(c, 5.5, Stroke::new(1.0, t.canvas)); + } else { + p.circle_filled(c, 4.0, color); + } + } + // input / output readout of the point being dragged (0–255 like the histogram) + if let Some(q) = dragging.and_then(|i| pts.get(i)) { + p.text( + r.left_top() + vec2(6.0, 4.0), + egui::Align2::LEFT_TOP, + format!("{} / {}", (q.x * 255.0).round(), (q.y * 255.0).round()), + t.font(11.0), + t.text_dim, + ); + } + let mut new_pts = None; + if resp.double_clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some((i, dist)) = nearest(q) + && dist < 10.0 + && i != 0 + && i != pts.len() - 1 + { + let mut v = pts.clone(); + v.remove(i); + new_pts = Some(v); + } else if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + // pick the point under the press, not where the pointer is once the drag threshold passed + let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"})); + match nearest(q) { + Some((i, dist)) if dist < 10.0 => dragging = Some(i), + _ => { + let (x, y) = from_screen(q); + let mut v = pts.clone(); + let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); + v.insert(i, Point::new(x, y)); + dragging = Some(i); + new_pts = Some(v); + } + } + } else if resp.dragged() + && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) + { + let mut v = pts.clone(); + // ⌥-drag = fine adjustment, as in Lightroom: the point moves a tenth of the pointer's + // motion (relative to where it is, so pressing ⌥ mid-drag doesn't make it jump) + let fine = ui.input(|i| i.modifiers.alt); + let (mut x, y) = match v.get(i) { + Some(cur) if fine => { + let dd = ui.input(|i| i.pointer.delta()); + ((cur.x + (dd.x / r.width()) as f64 * 0.1).clamp(0.0, 1.0), (cur.y - (dd.y / r.height()) as f64 * 0.1).clamp(0.0, 1.0)) + } + _ => from_screen(q), + }; + if i < v.len() { + let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 }; + let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 }; + if i == 0 { + x = x.min(hi); + } else if i + 1 == v.len() { + x = x.max(lo); + } else if lo <= hi { + x = x.clamp(lo, hi); + } else { + // neighbours closer than the 0.02 spacing (two clicks side by side, or points + // from a file) leave no room: the point stays midway (`clamp` would panic: min above max) + x = (v[i - 1].x + v[i + 1].x) / 2.0; + } + v[i] = Point::new(x, y); + new_pts = Some(v); + } + } else if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && nearest(q).is_none_or(|(_, dist)| dist >= 10.0) + { + let (x, y) = from_screen(q); + let mut v = pts.clone(); + let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); + v.insert(i, Point::new(x, y)); + new_pts = Some(v); + } + if let Some(v) = new_pts { + let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect(); + let _ = app.run("develop.curve", json!({"channel": ch, "points": arr})); + } + // the drag is over when egui says so, or when the pointer comes up without it saying so (a + // release outside the window would otherwise leave the interaction open, and every later edit a + // silent preview that Undo cannot take back) + if commit_interaction(app, ui, dragging.is_some(), resp.drag_stopped()) { + dragging = None; + } + ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); + curve_footer(app, ui, d); + control(app, ui, d, "curve.refineSaturation", true); + let _ = id; +} + +/// The row under the curve graph: point-curve presets and reset every curve. +fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + use lightcraft_engine::cmd::curves::{all_presets, matching_preset}; + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label)); + let current = matching_preset(&app.session, &d.curve); + let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name)); + let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin); + let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text); + egui::Popup::menu(&r).show(|ui| { + ui.set_min_width(180.0); + let presets = all_presets(&app.session); + let mut user_seen = false; + for p in &presets { + if !p.builtin && !user_seen { + user_seen = true; + ui.separator(); + } + let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)); + register(ui, format!("curvePreset:{}", p.name), r.rect); + if r.clicked() { + let _ = app.run("curve.applyPreset", json!({"name": p.name})); + ui.close(); + } + if !p.builtin { + crate::widgets::context_menu(&r, |ui| { + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { + let _ = app.run("curve.deletePreset", json!({"name": p.name})); + ui.close(); + } + }); + } + } + ui.separator(); + let shaped = current.as_deref() != Some("Linear"); + let r = ui.add_enabled(shaped, egui::Button::new(crate::i18n::tr("Save Point Curve…"))); + register(ui, "curvePresetMenu:save", r.rect); + if r.clicked() { + crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name"); + ui.close(); + } + if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { + let _ = app.run("file.importCurvePresets", json!({})); + ui.close(); + } + let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty(); + if ui.add_enabled(can_export, egui::Button::new(crate::i18n::tr("Export Presets…"))).clicked() { + let _ = app.run("file.exportCurvePresets", json!({})); + ui.close(); + } + if presets.iter().any(|p| !p.builtin) { + ui.label(RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = text_button(ui, "curveReset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr( + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)", + )); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": "all"})); + } + }); + }); + }); +} + +/// Right-click menu of the curve graph. +fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { + let label = if ch == "parametric" { + crate::i18n::tr("Reset Parametric Curve").to_string() + } else { + crate::i18n::tr_format!("Reset {} Channel", crate::i18n::tr(channel_label(ch))) + }; + let r = ui.button(label); + register(ui, "curveMenu:resetChannel", r.rect); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": ch})); + ui.close(); + } + let r = ui.button(crate::i18n::tr("Reset All Curves")); + register(ui, "curveMenu:resetAll", r.rect); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": "all"})); + ui.close(); + } +} + +fn channel_label(ch: &str) -> &'static str { + match ch { + "red" => "Red", + "green" => "Green", + "blue" => "Blue", + _ => "RGB", + } +} + +/// Toggle for a targeted-adjustment tool (`tool` = `tat:`). +fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { + let active = app.ui.tool == tool; + let id = tool.replace(':', "-"); + if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { + set_tat(app, ui.ctx(), tool, active); + } +} + +/// Turn targeted-adjustment tool `tool` off (`active`) or on. Turning it on says how to use and +/// leave it: while it is on, dragging on the photo adjusts instead of zooming or panning, which is +/// easy to miss when the button looks like a tab. +pub(super) fn set_tat(app: &mut LightcraftApp, ctx: &egui::Context, tool: &str, active: bool) { + if active { + app.ui.tool.clear(); + } else { + app.ui.tool = tool.to_string(); + app.toast(ctx, crate::i18n::tr("Targeted adjustment on: drag up or down on the photo · Esc to stop")); + } +} + +#[cfg(test)] +mod curve_histogram_tests { + use super::*; + + #[test] + fn curve_backdrop_tracks_luma_and_selected_channel() { + let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] }); + let h = lightcraft_raster::Histogram::of_srgb8(&img); + let red = curve_histogram_profile(&h, "red"); + let green = curve_histogram_profile(&h, "green"); + let luma = curve_histogram_profile(&h, "master"); + assert!(red[32] > red[150] && red[224] > red[150]); + assert!(green[100] > green[32] && green[100] > green[224]); + assert!(luma.iter().any(|v| *v > 0.0)); + } + + #[test] + fn hdr_backdrop_uses_only_the_sdr_bins_and_never_overflows() { + let h = lightcraft_raster::Histogram::of_hdr(&[[0.1, 0.1, 0.1], [1.0, 1.0, 1.0], [8.0, 8.0, 8.0]], 4.0); + let values = curve_histogram_profile(&h, "parametric"); + assert!(values.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + assert!(values.iter().any(|v| *v > 0.0)); + } +} diff --git a/crates/ui-egui/src/panels/edit/grading.rs b/crates/ui-egui/src/panels/edit/grading.rs new file mode 100644 index 000000000..a0b430acd --- /dev/null +++ b/crates/ui-egui/src/panels/edit/grading.rs @@ -0,0 +1,418 @@ +use super::sliders::control; +use super::*; + +// ------------------------------------------------------------------------------ colour grading + +/// The colour grading tabs: key, tooltip, icon. +const GRADING_TABS: [(&str, &str, Icon); 4] = [ + ("3way", "3-Way", Icon::GradeAll), + ("shadows", "Shadows", Icon::GradeShadows), + ("midtones", "Midtones", Icon::GradeMidtones), + ("highlights", "Highlights", Icon::GradeHighlights), +]; + +/// The wheels, in the order of `UiState::grading_hidden`. +const GRADING_WHEELS: [(&str, &str); 3] = [("shadows", "Shadows"), ("midtones", "Midtones"), ("highlights", "Highlights")]; + +pub(super) fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId) { + let mode = GRADING_TABS.iter().find(|(k, ..)| *k == app.ui.grading_mode).map_or("3way", |(k, ..)| *k); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (key, tip, icon) in GRADING_TABS { + if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)) + .clicked() + { + app.ui.grading_mode = key.to_string(); + } + } + }); + }); + let w = ui.available_width(); + if mode == "3way" { + // three wheels of one size: midtones on top, shadows and highlights below + let rad = 40.0; + let (area, _) = ui.allocate_exact_size(vec2(w, 276.0), Sense::hover()); + let at = [ + pos2(area.left() + w * 0.28, area.top() + 196.0), + pos2(area.center().x, area.top() + 70.0), + pos2(area.left() + w * 0.72, area.top() + 196.0), + ]; + for (i, c) in at.into_iter().enumerate() { + grading_wheel(app, ui, d, photo, i, c, rad, true); + } + for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum"] { + control(app, ui, d, c, true); + } + } else { + let i = GRADING_WHEELS.iter().position(|(k, _)| *k == mode).unwrap_or(1); + let rad = (w * 0.3).clamp(40.0, 80.0); + let (area, _) = ui.allocate_exact_size(vec2(w, rad * 2.0 + 40.0), Sense::hover()); + grading_wheel(app, ui, d, photo, i, pos2(area.center().x, area.top() + rad + 10.0), rad, false); + for part in ["hue", "sat", "lum"] { + control(app, ui, d, &format!("grading.{mode}.{part}"), true); + } + } + for c in ["grading.blending", "grading.balance"] { + control(app, ui, d, c, true); + } +} + +/// One colour grading wheel (`i` into [`GRADING_WHEELS`]) centred on `c`: drag to pick hue and +/// saturation, double-click to reset, and an eye at its bottom-left corner that hides its effect +/// on the photo for viewing. `readout`: show hue and saturation centred above the wheel while +/// hovered (3-Way); otherwise the wheel is the large one and gets saturation guide rings. +#[allow(clippy::too_many_arguments)] +fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId, i: usize, c: Pos2, rad: f32, readout: bool) { + let t = Tokens::get(ui.ctx()); + let Some(&(key, label)) = GRADING_WHEELS.get(i) else { return }; + let wh = match key { + "shadows" => d.grading.shadows, + "midtones" => d.grading.midtones, + _ => d.grading.highlights, + }; + let hidden = app.ui.grading_hidden_for(photo).get(i).copied().unwrap_or(false); + let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); + // the corners (eye, readout) count as the wheel for hovering + let zone = r.expand(10.0); + let hovering = ui.rect_contains_pointer(zone); + let resp = ui.interact(r, egui::Id::unique(("wheel", key)), Sense::click_and_drag()); + crate::widgets::announce(&resp, format!("{} {key}", crate::i18n::tr("Color wheel"))); + let resp = resp.on_hover_text(crate::i18n::tr( + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.", + )); + register(ui, format!("wheel:{key}"), r); + paint_wheel(ui.painter(), c, rad); + if hidden { + ui.painter().circle_filled(c, rad, Color32::from_black_alpha(150)); + } + // the large single wheel: dashed rings at 25 / 50 / 75 % saturation to judge strength by eye + if !readout { + for f in [0.25f32, 0.5, 0.75] { + let ring_rad = rad * f; + let n = (ring_rad * std::f32::consts::TAU / 2.0).max(8.0) as usize; + let ring: Vec = (0..=n).map(|k| c + egui::Vec2::angled(k as f32 / n as f32 * std::f32::consts::TAU) * ring_rad).collect(); + ui.painter().extend(egui::Shape::dashed_line(&ring, Stroke::new(0.8, Color32::from_white_alpha(45)), 4.0, 3.5)); + } + } + let a = (wh.hue as f32).to_radians(); + let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); + // Modifiers (as in Lightroom): Shift keeps the hue, Cmd/Ctrl keeps the saturation, Alt moves + // the puck a tenth as far as the pointer. Pressing or releasing one mid-drag re-grabs the + // puck as it is at that moment. + let grab_id = egui::Id::unique(("wheelGrab", key)); + let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); + let pointer = wheel_pointer(ui, &resp); + let grab = match pointer { + Some(q) if resp.dragged() || resp.clicked() => { + let held = ui.data(|m| m.get_temp::(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); + Some(held.unwrap_or_else(|| { + // a fine drag counts the pointer's travel from the press, not from the frame + // egui first called it a drag + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(q) } else { q }; + let g = WheelGrab::new(mods, wh.hue as f32, wh.sat as f32, origin - c, rad); + ui.data_mut(|m| m.insert_temp(grab_id, g)); + g + })) + } + _ => { + ui.data_mut(|m| m.remove::(grab_id)); + None + } + }; + // while a constraint is held: the guide for the one move left (the whole spoke for the hue, + // the circle for the saturation) + let guide = Stroke::new(1.5, Color32::from_white_alpha(170)); + match grab.and_then(|g| g.lock()) { + Some(WheelLock::Hue(h)) => { + let a = h.to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], guide); + } + Some(WheelLock::Sat(s)) => { + ui.painter().circle_stroke(c, rad * s / 100.0, guide); + } + None => {} + } + // the neutral centre (issue #559), under the point + ui.painter().circle_filled(c, 2.0, Color32::WHITE); + ui.painter().circle_filled(dot, 5.0, if hidden { Color32::from_gray(150) } else { Color32::WHITE }); + ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); + ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); + if resp.drag_started() { + let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); + } + if (resp.dragged() || resp.clicked()) + && let Some(q) = pointer + { + let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); + } + if resp.drag_stopped() || resp.clicked() { + let _ = app.run("develop.endInteraction", json!({})); + } + // released, or Esc (egui ends a drag on it) + if resp.drag_stopped() + && let Some(held) = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::unique(WHEEL_CAPTURE))) + && !held.dropped + { + give_pointer_back(ui.ctx(), held.origin); + } + if resp.double_clicked() { + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); + } + // hue and saturation readout: centred above the wheel, shown like the eye (no fade) + if readout && (hovering || resp.dragged()) { + // set a little below the gap's midpoint: the eye reads it as centred there + ui.painter().text( + pos2(c.x, r.top() - 13.0), + Align2::CENTER_CENTER, + format!("H {}° S {}", wh.hue.round() as i64, wh.sat.round() as i64), + t.font(11.0), + t.text_label, + ); + } + // the eye: on top of the wheel, shown while hovering or while the wheel is hidden + if hovering || hidden { + let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); + let er = ui.interact(eye, egui::Id::unique(("wheelEye", key)), Sense::click()); + crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); + register(ui, format!("wheelEye:{key}"), eye); + paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); + let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); + if er.clicked() { + app.ui.set_grading_hidden(photo, i, !hidden); + } + } +} + +/// A wheel's modifier state. Shift keeps the hue, Cmd/Ctrl the saturation (Shift wins when both +/// are down: keeping both would freeze the puck), Alt fine-adjusts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct WheelMods { + pub keep_hue: bool, + pub keep_sat: bool, + pub fine: bool, +} + +impl WheelMods { + pub(crate) fn from_modifiers(m: egui::Modifiers) -> Self { + Self { keep_hue: m.shift, keep_sat: m.command && !m.shift, fine: m.alt } + } +} + +/// What a constraint holds still while the pointer moves. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum WheelLock { + /// Keep this hue (degrees): only the saturation changes, along the spoke at that hue. + Hue(f32), + /// Keep this saturation (0..100): only the hue changes, round the circle at that radius. + Sat(f32), +} + +/// Pointer travel to puck travel with Alt held, as for a slider's fine adjust. +const WHEEL_FINE: f32 = 0.1; + +/// The puck as it was when the pointer grabbed it (or a modifier last changed): the hue and +/// saturation a constraint keeps, and the origin a fine adjust measures the pointer's travel from. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct WheelGrab { + pub mods: WheelMods, + /// The puck's hue (degrees) and saturation (0..100) at the grab. + hue: f32, + sat: f32, + /// The pointer at the grab, as an offset from the wheel's centre. + origin: egui::Vec2, +} + +impl WheelGrab { + /// Grab a puck at `hue` / `sat` with the pointer `origin` from the centre of a wheel of + /// radius `rad`. The kept hue is the puck's own, never the angle the pointer happens to be + /// at. A puck at the centre (saturation under 1) has no hue yet, so there it is the angle of + /// the pointer at the grab; the same rule covers a non-finite hue. + pub(crate) fn new(mods: WheelMods, hue: f32, sat: f32, origin: egui::Vec2, rad: f32) -> Self { + let sat = if sat.is_finite() { sat.clamp(0.0, 100.0) } else { 0.0 }; + let hue = if hue.is_finite() && sat >= 1.0 { hue.rem_euclid(360.0) } else { wheel_pick(origin, rad, None).0 }; + Self { mods, hue, sat, origin } + } + + /// The constraint this grab holds, if any. + pub(crate) fn lock(&self) -> Option { + if self.mods.keep_hue { + Some(WheelLock::Hue(self.hue)) + } else if self.mods.keep_sat { + Some(WheelLock::Sat(self.sat)) + } else { + None + } + } + + /// The new hue and saturation for the pointer at offset `v` from the centre. + pub(crate) fn pick(&self, v: egui::Vec2, rad: f32) -> (f32, f32) { + let v = if self.mods.fine { + // the puck where it was, moved a tenth of the pointer's travel since the grab + let a = self.hue.to_radians(); + let puck = vec2(a.cos(), -a.sin()) * rad.max(1.0) * (self.sat / 100.0); + puck + (v - self.origin) * WHEEL_FINE + } else { + v + }; + wheel_pick(v, rad, self.lock()) + } +} + +/// A wheel drag holds the pointer, as in Lightroom (issue #1899): it is hidden for the drag, the +/// puck follows the mouse's own travel, and on release the pointer is back where the button went +/// down, so a long (Alt) drag doesn't leave it far from the wheels. On macOS the pointer is locked +/// in place for the drag; elsewhere it moves unseen and is put back. The browser can't move it +/// (eframe's web host ignores the command), so there it only shows again where the drag ended. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct WheelCapture { + /// Where the button went down: the pointer goes back there. + origin: Pos2, + /// Where the pointer would be had it been free: where the drag began plus the travel since. + pos: Pos2, + /// Let go early (the window lost focus): the rest of this drag leaves the wheel alone. + dropped: bool, + /// The pass that last drew the dragged wheel. + pass: u64, +} + +/// One wheel is dragged at a time, so one capture for them all. +const WHEEL_CAPTURE: &str = "wheelCapture"; + +/// The mouse's travel this frame: the pointer's, or, while the pointer stands still (locked, or +/// against a screen edge), the raw motion the host reports. +fn wheel_travel(delta: egui::Vec2, motion: Option) -> egui::Vec2 { + if delta != egui::Vec2::ZERO { + return delta; + } + motion.filter(|m| m.x.is_finite() && m.y.is_finite()).unwrap_or(egui::Vec2::ZERO) +} + +/// Where the wheel `resp` reads the pointer this frame: where it clicked, or for a drag the held +/// pointer ([`WheelCapture`]), `None` once a lost focus let go of it (Esc ends the drag itself: +/// egui stops a drag on Esc). +fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option { + let q = resp.interact_pointer_pos()?; + if !resp.dragged() { + return Some(q); + } + let ctx = ui.ctx(); + let id = egui::Id::unique(WHEEL_CAPTURE); + let held = ctx.data(|m| m.get_temp::(id)).filter(|_| !resp.drag_started()); + let mut cap = held.unwrap_or_else(|| { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked)); + } + WheelCapture { origin: ui.input(|i| i.pointer.press_origin()).unwrap_or(q), pos: q, ..Default::default() } + }); + cap.pass = ctx.cumulative_pass_nr(); + if !cap.dropped { + let (travel, unfocused) = ui.input(|i| { + // the event, not `focused`: a click that activates the window may land before its focus + let unfocused = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))); + (wheel_travel(i.pointer.delta(), i.pointer.motion()), unfocused) + }); + if !resp.drag_started() { + cap.pos += travel; + } + if unfocused { + give_pointer_back(ctx, cap.origin); + cap.dropped = true; + } else { + ctx.set_cursor_icon(egui::CursorIcon::None); + } + } + ctx.data_mut(|m| m.insert_temp(id, cap)); + (!cap.dropped).then_some(cap.pos) +} + +/// Unlock and show the pointer a wheel drag held, back at `origin`. +fn give_pointer_back(ctx: &egui::Context, origin: Pos2) { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)); + } + if cfg!(not(target_arch = "wasm32")) { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); + } +} + +/// Once a frame, after the panels: a held pointer stays hidden over whatever set its own cursor, +/// and is given back if no wheel drew this pass (the flyout closed or the view changed under the +/// drag), so it is never left locked or hidden. +pub(crate) fn wheel_pointer_frame_end(ctx: &egui::Context) { + let id = egui::Id::unique(WHEEL_CAPTURE); + let Some(cap) = ctx.data(|m| m.get_temp::(id)) else { return }; + if cap.pass != ctx.cumulative_pass_nr() { + ctx.data_mut(|m| m.remove::(id)); + if !cap.dropped { + give_pointer_back(ctx, cap.origin); + } + } else if !cap.dropped { + ctx.set_cursor_icon(egui::CursorIcon::None); + } +} + +/// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. +/// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the +/// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked +/// value, else 0). +pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { + let rad = rad.max(1.0); + let v = if v.x.is_finite() && v.y.is_finite() { v } else { egui::Vec2::ZERO }; + let angle = || ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; + match lock { + Some(WheelLock::Hue(h)) => { + let h = if h.is_finite() { h } else { 0.0 }; + let a = h.to_radians(); + let along = v.x * a.cos() - v.y * a.sin(); + (h, (along / rad * 100.0).clamp(0.0, 100.0)) + } + Some(WheelLock::Sat(s)) => (angle(), if s.is_finite() { s.clamp(0.0, 100.0) } else { 0.0 }), + None => (angle(), (v.length() / rad * 100.0).min(100.0)), + } +} + +fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { + p.add(wheel_mesh(c, rad)); + p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); +} + +/// The picture of a grading wheel: a hue disc whose rim is fully saturated display sRGB at full +/// brightness, fading to a neutral grey at the centre. It is built from concentric rings so the +/// saturation ramps evenly with the radius instead of one fan of triangles blending each hue +/// straight into grey (which made the whole disc look muddy, issue #77). Only the drawing: +/// what a position means for the edit is [`wheel_pick`]. +pub(crate) fn wheel_mesh(c: Pos2, rad: f32) -> Mesh { + const SEGMENTS: u32 = 72; + const RINGS: u32 = 12; + /// Brightness (HSV value) at the centre; the rim is 1.0. + const CENTRE_V: f32 = 0.6; + let colour = |hue_deg: f32, t: f32| { + let rgb = lightcraft_color::perceptual::hsv_to_rgb(hue_deg, t, CENTRE_V + (1.0 - CENTRE_V) * t); + let u = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(u(rgb[0]), u(rgb[1]), u(rgb[2])) + }; + let mut mesh = Mesh::default(); + mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: colour(0.0, 0.0) }); + // ring k (1..=RINGS) holds SEGMENTS + 1 vertices starting at index 1 + (k - 1) * (SEGMENTS + 1) + let row = SEGMENTS + 1; + for k in 1..=RINGS { + let t = k as f32 / RINGS as f32; + for i in 0..=SEGMENTS { + let a = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad * t, uv: Pos2::ZERO, color: colour(a.to_degrees(), t) }); + } + } + for i in 0..SEGMENTS { + mesh.indices.extend([0, 1 + i, 2 + i]); + } + for k in 1..RINGS { + let inner = 1 + (k - 1) * row; + let outer = inner + row; + for i in 0..SEGMENTS { + mesh.indices.extend([inner + i, outer + i, outer + i + 1, inner + i, outer + i + 1, inner + i + 1]); + } + } + mesh +} diff --git a/crates/ui-egui/src/panels/edit/histogram.rs b/crates/ui-egui/src/panels/edit/histogram.rs new file mode 100644 index 000000000..44d791c4a --- /dev/null +++ b/crates/ui-egui/src/panels/edit/histogram.rs @@ -0,0 +1,353 @@ +#[cfg(test)] +use super::sliders::reset_tip; +use super::*; + +// ------------------------------------------------------------------------------ histogram + +/// The current photo's existing preview histogram, shared by Histogram and Tone Curve. +pub(super) fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option { + app.renderer + .textures + .get(&Slot::Main) + .filter(|t| t.photo == id) + .and_then(|t| t.histogram.clone()) + .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())) +} + +/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's +/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to +/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so +/// the histogram still follows the edit live; an SDR edit shows what it gets. +fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option) -> Option { + let key = egui::Id::unique(("hdrHistogram", id)); + match hist { + Some(h) if h.hdr.is_some() => { + ctx.data_mut(|d| d.insert_temp(key, h.clone())); + Some(h) + } + h if hdr_edit => ctx.data(|d| d.get_temp::(key)).or(h), + h => h, + } +} + +pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); + // a drag of an HDR edit: its drafts have no HDR histogram, so a small render beside them + // gives one (at its own pace: the drafts don't wait for it) + let interacting = app.session.interaction.is_some(); + if interacting && hdr_edit { + let edge = lightcraft_pipeline::HDR_HISTOGRAM_DRAG_EDGE; + if let Some(job) = app.session.render_job(id, edge, edge, false, app.ui.right != RightPanel::Crop) + && app.renderer.live_hdr_histogram.as_ref().is_none_or(|l| l.1 != job.key) + { + app.renderer.request(Slot::HdrHistogram, job, 95); + } + } + let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); + // else the loupe render of this photo, else its thumbnail (grid view before the loupe has + // rendered it) + let hist = live.or_else(|| preview_histogram(app, id)); + let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist); + // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label + // row under the plot + let hdr = hist.as_ref().and_then(|h| h.hdr); + let legend = if hdr.is_some() { 26.0 } else { 0.0 }; + let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); + register(ui, "histogram", r); + let p = ui.painter(); + p.rect_filled(r, 0.0, t.button); + let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend)); + // Lightroom Classic's tonal zones (Blacks / Shadows / Exposure / Highlights / Whites across the + // plot): a drag over one moves its control (the band and the readout show which and how far), a + // double-click puts it back to its default, and Shift drags a tenth as far + let zone = crate::panels::histogram::interact(app, ui, &resp, plot, id); + if let Some(a) = &zone { + crate::panels::histogram::paint_zone(p, plot, a, &t); + } + if let Some(b) = hdr { + // white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR + // range on) in its colours + let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; + let bar = |a: f32, z: f32, c: Color32| p.rect_filled(Rect::from_min_max(pos2(a, plot.bottom() + 2.0), pos2(z, plot.bottom() + 7.0)), 0.0, c); + let white = Color32::from_rgb(228, 228, 230); + bar(plot.left(), x0 - 1.0, white); + let stops = b.stops.round().max(1.0) as usize; + let bands = lightcraft_pipeline::visualize::HDR_BANDS; + let colours = app.ui.hdr_visualize; + for k in 0..stops { + let c = if colours { bands.get(k).or(bands.last()).copied().unwrap_or([255, 255, 255]) } else { [228, 228, 230] }; + let (a, z) = (x0 + (plot.right() - x0) * k as f32 / stops as f32, x0 + (plot.right() - x0) * (k + 1) as f32 / stops as f32); + bar(a + 1.0, z - 1.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let y = plot.bottom() + 18.0; + p.text(pos2((plot.left() + x0) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("SDR"), t.font(12.5), t.text_label); + p.text(pos2((x0 + plot.right()) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("HDR"), t.font(12.5), t.text_label); + let info = Rect::from_center_size(pos2(plot.right() - 16.0, y), vec2(14.0, 14.0)); + paint(p, info, Icon::Info, t.text_label); + ui.interact(info, egui::Id::unique("hdrHistogramInfo"), Sense::hover()).on_hover_text(crate::i18n::tr( + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", + )); + } + let clipping = hist.as_ref().map(|h| h.clipping()); + if let Some(h) = hist { + let smooth = |v: &[u32]| -> Vec { + (0..256) + .map(|i| { + let (a, b) = (i.max(2) - 2, (i + 3).min(256)); + v[a..b].iter().map(|x| *x as f32).sum::() / (b - a) as f32 + }) + .collect() + }; + // HDR: SDR on the left half, a solid line at SDR white and dashed ones between the stops + if let Some(b) = h.hdr { + let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; + let stops = b.stops.round().max(1.0) as usize; + for k in 1..stops { + let x = x0 + (plot.right() - x0) * k as f32 / stops as f32; + let mut y = plot.top(); + while y < plot.bottom() { + p.line_segment([pos2(x, y), pos2(x, (y + 5.0).min(plot.bottom()))], Stroke::new(1.0, Color32::from_gray(150))); + y += 9.0; + } + } + p.line_segment([pos2(x0, plot.top()), pos2(x0, plot.bottom())], Stroke::new(1.5, Color32::from_gray(160))); + // the photo's headroom limit on the fixed scale: marked, the range past it dimmed + let limit = app.session.develop_of(id).map_or(b.stops, |d| d.hdr.max_ev as f32); + if limit.is_finite() && limit >= 0.0 && limit < b.stops - 0.05 { + let x = x0 + (plot.right() - x0) * (limit / b.stops).clamp(0.0, 1.0); + p.rect_filled(Rect::from_min_max(pos2(x, plot.top()), plot.max), 0.0, Color32::from_black_alpha(70)); + p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, Color32::from_rgb(230, 150, 60))); + } + } + let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))]; + let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b)); + let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0)); + // luminance fill + let lum = smooth(&h.luma); + let mut mesh = Mesh::default(); + for i in 0..256 { + let top = to(i, lum[i]); + mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); + mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); + if i > 0 { + let k = (i as u32) * 2; + mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); + } + } + p.add(mesh); + // greyscale images: the three channels coincide — draw one neutral line + let spread: f32 = (0..256).map(|i| (chans[0].0[i] - chans[1].0[i]).abs() + (chans[1].0[i] - chans[2].0[i]).abs()).sum(); + let total: f32 = chans[1].0.iter().sum::().max(1.0); + if spread / total < 0.02 { + let pts: Vec = (0..256).map(|i| to(i, chans[1].0[i])).collect(); + p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_gray(200)))); + } else { + for (v, c) in &chans { + let pts: Vec = (0..256).map(|i| to(i, v[i])).collect(); + p.add(egui::Shape::line(pts, Stroke::new(1.5, *c))); + } + } + } + let mut clicked = resp.clicked(); + // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep + // clear of it, the left one too so they stay symmetric (#1640) + let [shadow_rect, highlight_rect] = crate::panels::histogram::clipping_rects(ui, plot); + for (name, rect, label, clipped) in [ + ("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", highlight_rect, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + ] { + let response = ui.interact(rect, ui.scope_id().with(("histogram-clipping", name)), Sense::click()); + register(ui, format!("histogram:{name}"), rect); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); + clicked |= response.clicked(); + let active = app.ui.show_clipping; + // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. + let box_rect = rect.shrink2(vec2(2.0, 2.0)); + p.rect_filled( + box_rect, + 3.0, + if active { + t.pressed + } else if response.hovered() { + t.hover + } else { + Color32::from_gray(60) + }, + ); + if active { + p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + } + paint( + p, + Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), + Icon::ChevronDown, + if clipped == Some(true) || active { t.text } else { t.text_dim }, + ); + let status = match clipped { + Some(true) => "Clipping detected in this preview.", + Some(false) => "No clipping detected in this preview.", + None => "Histogram not yet available.", + }; + response.on_hover_text(format!( + "{}\n{}\n{}", + crate::i18n::tr(label), + crate::i18n::tr(if active { + "Click to hide clipping warnings for shadows and highlights." + } else { + "Click to show clipping warnings for shadows and highlights." + }), + crate::i18n::tr(status) + )); + } + if clicked { + let _ = app.run("view.clipping", json!({})); + } + // the tonal zone the pointer is on replaces the EXIF strip while it is there + if let Some(text) = zone.as_ref().and_then(|a| a.readout()) { + register(ui, "histogramReadout", r); + p.text(pos2(r.left() + r.width() / 2.0, r.bottom() - 13.0), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); + } else if let Some(ph) = app.session.catalog.photo(id) { + let m = &ph.meta; + let parts: Vec = [ + m.iso.map(|i| format!("ISO {i}")), + m.focal_mm.map(|f| format!("{f:.0}mm")), + m.aperture.map(|a| format!("f/{a:.1}")), + if m.shutter.is_empty() { None } else { Some(m.shutter.clone()) }, + ] + .into_iter() + .flatten() + .collect(); + + if !parts.is_empty() { + let text = parts.join(" · "); + let y = r.bottom() - 13.0; + let x = r.left() + r.width() / 2.0; + p.text(pos2(x, y), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); + } + } +} + +// ------------------------------------------------------------------------------ soft proofing + +/// The Soft Proofing strip under the histogram: proof profile, gamut warnings, Create Proof Copy. +pub(super) fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + use lightcraft_engine::pipeline::OutputSpace; + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Soft Proofing")).color(t.text).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut dest = app.ui.proof.dest_warning; + let r = ui + .toggle_value(&mut dest, RichText::new("■").color(Color32::from_rgb(255, 40, 40))) + .on_hover_text(crate::i18n::tr("Show destination gamut warning")); + register(ui, "button:proofDestWarning", r.rect); + let mut disp = app.ui.proof.display_warning; + let r2 = ui + .toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))) + .on_hover_text(crate::i18n::tr("Show display gamut warning")); + register(ui, "button:proofDisplayWarning", r2.rect); + if dest != app.ui.proof.dest_warning || disp != app.ui.proof.display_warning { + let _ = app.run("view.softProof", json!({"destWarning": dest, "displayWarning": disp})); + } + }); + }); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label)); + let cur = app.ui.proof.space; + let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); + let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); + let mut pick = false; + let combo = crate::widgets::combo_box("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { + for s in OutputSpace::ALL { + if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { + let _ = app.run("view.softProof", json!({"space": s})); + } + } + if let Some(n) = &device { + let _ = ui.selectable_label(true, n); + } + if app.services.pick_display_profile.is_some() && ui.selectable_label(false, crate::i18n::tr("Other…")).clicked() { + pick = true; + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Profile")); + if pick + && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) + && let Err(e) = app.run("view.softProof", json!({"profile": path})) + { + app.toast(ui.ctx(), e); + } + }); + if app.ui.proof_profile.is_some() { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Intent:")).color(t.text_label)); + use lightcraft_engine::proof::ProofIntent; + for (i, label) in [(ProofIntent::Perceptual, "Perceptual"), (ProofIntent::Relative, "Relative")] { + let r = ui.selectable_label(app.ui.proof_intent == i, crate::i18n::tr(label)); + register(ui, format!("button:proofIntent-{}", i.name()), r.rect); + if r.clicked() && app.ui.proof_intent != i { + let _ = app.run("view.softProof", json!({"intent": i.name()})); + } + } + }); + let mut paper = app.ui.proof_paper_ink; + let r = ui.checkbox(&mut paper, crate::i18n::tr("Simulate Paper & Ink")); + register(ui, "button:proofPaperInk", r.rect); + if r.changed() { + let _ = app.run("view.softProof", json!({"paperInk": paper})); + } + } + let r = ui.button(crate::i18n::tr("Create Proof Copy")); + register(ui, "button:createProofCopy", r.rect); + if r.clicked() { + let target = app.ui.proof_profile.as_ref().map_or_else(|| app.ui.proof.space.label().to_string(), |(_, n)| n.clone()); + let name = crate::i18n::tr_format!("Proof Copy ({})", target); + if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { + app.toast(ui.ctx(), crate::i18n::tr("Proof copy created")); + } + } + }); + divider(ui); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { + let ctx = egui::Context::default(); + let id = PhotoId(7); + let sdr = Histogram::of_hdr(&[], 5.0); + let sdr = Histogram { hdr: None, ..sdr }; + let hdr = |v: f32| Histogram::of_hdr(&[[v, v, v]], 5.0); + // nothing kept yet: what arrives + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(2.0))), Some(hdr(2.0))); + // a cached thumbnail's 8-bit histogram (or none) doesn't swap the panel to SDR + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(2.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, None), Some(hdr(2.0))); + // the next HDR render replaces it: the histogram follows the slider + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(4.0))), Some(hdr(4.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0))); + // HDR turned off: the SDR histogram at once; other photos keep their own + assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); + } + + /// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows. + #[test] + fn reset_tooltip_names_the_shortcut_of_this_platform() { + let keymap = crate::shortcuts::Keymap::new(); + assert_eq!(reset_tip(&keymap, false), "Reset all edits (Ctrl+Shift+R)"); + assert_eq!(reset_tip(&keymap, true), "Reset all edits (⌘⇧R)"); + let rebound: crate::shortcuts::Keymap = [("develop.reset".to_string(), "Cmd+Alt+R".to_string())].into(); + assert_eq!(reset_tip(&rebound, false), "Reset all edits (Ctrl+Alt+R)"); + let unbound: crate::shortcuts::Keymap = [("develop.reset".to_string(), String::new())].into(); + assert_eq!(reset_tip(&unbound, false), "Reset all edits"); + } +} diff --git a/crates/ui-egui/src/panels/edit/mixer.rs b/crates/ui-egui/src/panels/edit/mixer.rs new file mode 100644 index 000000000..b869b32dd --- /dev/null +++ b/crates/ui-egui/src/panels/edit/mixer.rs @@ -0,0 +1,146 @@ +use super::curve::set_tat; +use super::sliders::{control, sub_title}; +use super::*; + +// ------------------------------------------------------------------------------ colour mixer + +pub(super) fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + let bands = lightcraft_develop::MIXER_BANDS; + let sel = bands.iter().position(|b| *b == app.ui.mixer_mode).unwrap_or(0); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + for (i, b) in bands.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(b)); + register(ui, format!("mixerBand:{b}"), r); + ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i])); + if i == sel { + ui.painter().circle_stroke(r.center(), 10.5, Stroke::new(1.5, t.text)); + ui.painter().circle_filled(r.center(), 2.0, Color32::WHITE); + } + if resp.clicked() { + app.ui.mixer_mode = b.to_string(); + } + } + }); + }); + // targeted adjustment: pick the attribute, then drag on the photo + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); + paint(ui.painter(), r, Icon::Target, t.icon); + let items: &[(&str, &str)] = + if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] }; + for (k, label) in items { + let tool = format!("tat:{k}"); + let active = app.ui.tool == tool; + if text_button(ui, &format!("tatMixer-{k}"), crate::i18n::tr(label), active) + .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) + .clicked() + { + set_tat(app, ui.ctx(), &tool, active); + } + } + }); + }); + let b = bands[sel]; + if crate::is_bw(d) { + ui.horizontal(|ui| { + ui.add_space(24.0); + if crate::widgets::text_button(ui, "bwAuto", crate::i18n::tr("Auto"), false) + .on_hover_text(crate::i18n::tr("Set the mix from the photo's colours")) + .clicked() + { + let _ = app.run("develop.autoBwMix", json!({})); + } + }); + control(app, ui, d, &format!("bw.{b}"), true); + } else { + for k in ["hue", "sat", "lum"] { + control(app, ui, d, &format!("mixer.{b}.{k}"), true); + } + } +} + +// ------------------------------------------------------------------------------ point color + +/// Display colour of an OkLCh sample (lightness, chroma, hue in degrees). +fn oklch_color(l: f64, c: f64, h_deg: f64) -> Color32 { + use lightcraft_color::perceptual::{lch_to_lab, oklab_to_2020}; + let lin = oklab_to_2020(lch_to_lab([l as f32, c as f32, (h_deg as f32).to_radians()])); + let s = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).apply_f32(lin); + let e = s.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8); + Color32::from_rgb(e[0], e[1], e[2]) +} + +/// Point Color: swatches of the samples (+ the eyedropper), the selected sample's shifts and range, +/// and "Visualize range". +pub(super) fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + let n = d.point_colors.len(); + if app.ui.point_color >= n && n > 0 { + app.ui.point_color = n - 1; + } + let sel = app.ui.point_color; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + let active = app.ui.tool == "pointColor"; + let full = n >= lightcraft_develop::MAX_POINT_COLORS; + if crate::widgets::icon_button( + ui, + "pointColorPicker", + Icon::Picker, + vec2(26.0, 26.0), + active, + !full, + crate::i18n::tr("Sample a colour on the photo"), + ) + .clicked() + { + app.ui.tool = if active { String::new() } else { "pointColor".into() }; + } + for (i, p) in d.point_colors.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Point Color"), i + 1)); + register(ui, format!("pointColor:{i}"), r); + ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); + if i == sel { + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + } + if resp.clicked() { + app.ui.point_color = i; + } + } + }); + }); + if n == 0 { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Pick a colour on the photo with the eyedropper.")).size(12.0).color(t.text_dim)); + }); + return; + } + for k in ["hueShift", "satShift", "lumShift", "variance"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + sub_title(ui, crate::i18n::tr("Range")); + for k in ["range", "hueRange", "satRange", "lumRange"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| { + ui.horizontal(|ui| { + let mut v = app.ui.point_color_visualize; + if ui.checkbox(&mut v, crate::i18n::tr("Visualize range")).changed() { + app.ui.point_color_visualize = v; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, "pointColorDelete", crate::i18n::tr("Delete"), false).clicked() { + let _ = app.run("pointColor.delete", json!({"index": sel})); + } + }); + }); + }); +} diff --git a/crates/ui-egui/src/panels/edit/sliders.rs b/crates/ui-egui/src/panels/edit/sliders.rs new file mode 100644 index 000000000..aad9cdf6a --- /dev/null +++ b/crates/ui-egui/src/panels/edit/sliders.rs @@ -0,0 +1,303 @@ +use super::*; + +/// Commit a slider interaction: begin → live updates → end, so a drag is one undo step. +pub fn apply_slider_out( + app: &mut LightcraftApp, + spec: &ControlSpec, + out: SliderOut, + mut set: impl FnMut(&mut LightcraftApp, f64) -> Result, +) { + if out.drag_started && !out.reset { + let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); + app.ui.dragging_control = Some(spec.id.to_string()); + } + if let Some(v) = out.value { + let _ = set(app, v); + } + if out.drag_stopped || out.reset { + let _ = app.run("develop.endInteraction", json!({})); + app.ui.dragging_control = None; + } +} + +pub(crate) fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { + let Some(spec) = controls::find(id) else { return }; + let v = controls::get(d, id).unwrap_or(spec.default); + let out = slider(ui, spec, v, enabled, None); + apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": id, "value": v}))); +} + +/// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift. +pub(super) const REL_TEMP: ControlSpec = ControlSpec { + id: "wb.tempRel", + label: "Temp", + section: Section::Color, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Track::Temp, +}; + +pub(super) const REL_TINT: ControlSpec = ControlSpec { + id: "wb.tintRel", + label: "Tint", + section: Section::Color, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Track::Tint, +}; + +pub(super) fn k_to_rel(k: f64) -> f64 { + ((1e6 / 6500.0 - 1e6 / k) / 0.8).clamp(-100.0, 100.0) +} + +pub(super) fn rel_to_k(r: f64) -> f64 { + 1e6 / (1e6 / 6500.0 - r * 0.8) +} + +/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and +/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and +/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the +/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. +pub(super) fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); +} + +fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + use lightcraft_engine::presets::{PROFILES, profile_groups}; + let t = Tokens::get(ui.ctx()); + ui.set_min_width(200.0); + let cur = d.profile.id.as_str(); + // (clicked, hovered) + let mut pick: (Option, Option) = (None, None); + let item = |ui: &mut egui::Ui, key: &str, id: &str, name: &str, pick: &mut (Option, Option)| { + let r = ui.selectable_label(id == cur, crate::i18n::profile_label(id, name)); + register(ui, format!("profileMenu:{key}:{id}"), r.rect); + if r.clicked() { + pick.0 = Some(id.to_string()); + } + if r.hovered() { + pick.1 = Some(id.to_string()); + } + }; + let heading = |ui: &mut egui::Ui, s: &str| { + ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim)); + }; + for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { + let list: Vec<_> = ids.iter().filter_map(|id| app.session.profile_info(id).map(|(name, _)| (id.as_str(), name))).collect(); + if list.is_empty() { + continue; + } + heading(ui, title); + for (id, name) in list { + item(ui, key, id, name, &mut pick); + } + ui.separator(); + } + for g in profile_groups() { + let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p.id, p.name, &mut pick); + } + }); + }); + register(ui, format!("profileMenu:groupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } + } + // imported LUT profile groups + let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); + lut_groups.sort_unstable(); + lut_groups.dedup(); + for g in &lut_groups { + // a folder's name as it is, and its own widget id even when it matches a built-in group + let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); + } + }); + }); + register(ui, format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } + } + ui.separator(); + if let Some((name, _)) = app.session.profile_info(cur) { + let fav = app.session.profile_favorites.iter().any(|f| f == cur); + let label = if fav { + crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::profile_label(cur, name)) + } else { + crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::profile_label(cur, name)) + }; + let r = ui.button(label); + register(ui, "profileMenu:toggleFavorite", r.rect); + if r.clicked() { + let _ = app.run("profile.favorite", json!({"id": cur})); + } + } + let r = ui.button(crate::i18n::tr("Browse…")); + register(ui, "profileMenu:browse", r.rect); + if r.clicked() { + let _ = app.run("panel.profiles", json!({})); + } + // resting on a profile previews it in the loupe + if let Some(hovered_id) = &pick.1 + && hovered_id != cur + && let Some((name, _)) = app.session.profile_info(hovered_id) + { + let mut s = d.clone(); + s.profile.id = hovered_id.clone(); + s.profile.amount = 100.0; + app.hover_preview = + Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(hovered_id, name)), settings: s }); + } + if let Some(id) = pick.0 { + let _ = app.run("develop.profile", json!({"id": id})); + } +} + +pub fn sub_title(ui: &mut egui::Ui, title: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); + ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label); +} + +pub(super) fn section( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + d: &DevelopSettings, + id: &str, + title: &str, + body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui, &DevelopSettings), +) { + let open = app.ui.section_open(id); + let (resp, toggled) = section_header(ui, id, title, open, Some(d.section_enabled(id))); + if let Some(on) = toggled { + let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on})); + } else if resp.clicked() { + // ⌥-click opens just this section, as in Lightroom Classic + let solo = ui.input(|i| i.modifiers.alt); + if let Err(e) = app.run(&format!("section.{id}"), json!({"solo": solo})) { + app.toast(ui.ctx(), e); + } + } + crate::widgets::context_menu(&resp, |ui| { + let mut solo = app.ui.single_panel; + let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode")); + register(ui, "sectionMenu:solo", item.rect); + if item.clicked() { + if let Err(e) = app.run("view.soloSections", json!({"on": solo})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); + if open { + body(app, ui, d); + } + divider(ui); +} + +// ------------------------------------------------------------------------------ HDR + +/// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit. +/// The Light panel's HDR settings while HDR editing is on (the switch itself is the HDR button +/// next to B&W): Visualize HDR range and the headroom limit. +pub(super) fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + if !d.hdr.enabled { + return; + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { + let mut v = app.ui.hdr_visualize; + let r = ui.checkbox(&mut v, "Visualize HDR"); + register(ui, "check:hdrVisualize", r.rect); + if r.on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it").changed() { + app.ui.hdr_visualize = v; + } + }); + control(app, ui, d, "hdr.maxEv", true); +} + +/// Quick Develop (grid with several photos selected): relative steps applied to every selected +/// photo from its own value. +pub(super) fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr_format!("Quick Develop · {n} photos", n = n)).color(t.text_label).size(12.5)); + ui.add_space(4.0); + let rows: [(&str, &str, f64, f64); 9] = [ + ("Exposure", "light.exposure", 1.0 / 3.0, 1.0), + ("Contrast", "light.contrast", 5.0, 20.0), + ("Highlights", "light.highlights", 5.0, 20.0), + ("Shadows", "light.shadows", 5.0, 20.0), + ("Whites", "light.whites", 5.0, 20.0), + ("Blacks", "light.blacks", 5.0, 20.0), + ("Clarity", "effects.clarity", 5.0, 20.0), + ("Vibrance", "color.vibrance", 5.0, 20.0), + ("Temp", "wb.temp", 100.0, 500.0), + ]; + egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { + for (label, ctl, small, big) in rows { + ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); + // Keep the existing automation ids while drawing all step arrows as vectors. + for (txt, icon, d) in [ + ("◀◀", Icon::ChevronDoubleLeft, -big), + ("◀", Icon::ChevronLeft, -small), + ("▶", Icon::ChevronRight, small), + ("▶▶", Icon::ChevronDoubleRight, big), + ] { + let r = crate::widgets::arrow_button(ui, "", icon, false); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::Role::Button, + ui.is_enabled(), + crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)), + ) + }); + crate::widgets::register(ui, format!("button:quick-{ctl}-{txt}"), r.rect); + if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label))) + .clicked() + { + let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d})); + } + } + ui.end_row(); + } + }); + }); +} + +/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can +/// decode, whose drive was offline when the library opened) in the background; a toast says what +/// came of it once it's done (`crate::reload`). +pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + match app.run("photo.reload", json!({"ids": [id.0]})) { + Err(e) => app.toast_error(ctx, e), + Ok(r) if r["background"] == true => {} + // nothing to read in the background (a photo without a file): answered in place + Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { + app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) + } + Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), + } +} + +/// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). +pub(super) fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { + let base = crate::i18n::tr("Reset all edits"); + match crate::shortcuts::shortcut_of(keymap, "develop.reset") { + Some(sc) => format!("{base} ({})", crate::menubar::shortcut_text(sc, mac)), + None => base.to_string(), + } +} diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 592baf32f..8b34fbaf9 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -33,7 +33,7 @@ fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&e let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); crate::widgets::announce_selected(&resp, crate::i18n::tr(tip), on); - register(ui.ctx(), format!("filter:{id}"), r); + register(ui, format!("filter:{id}"), r); let p = ui.painter(); if on { p.rect_filled(r, 5.0, t.tool_active); @@ -100,7 +100,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), if two_rows { 2.0 * HEIGHT - 8.0 } else { HEIGHT }), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.chrome); ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.divider)); - register(ui.ctx(), "filterbar", bar); + register(ui, "filterbar", bar); let f = app.session.filter.clone(); let row = |ui: &mut egui::Ui, i: f32| { let r = Rect::from_min_size(bar.min + vec2(16.0, 4.0 + i * (HEIGHT - 8.0)), vec2(bar.width() - 32.0, HEIGHT - 8.0)); @@ -253,19 +253,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(14.0); let filtering = app.session.filter != Default::default(); let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new(crate::i18n::tr("Save as Smart Album…")).font(t.font(12.5)))); - register(ui.ctx(), "button:filterSave", save.rect); + register(ui, "button:filterSave", save.rect); if save.clicked() { let _ = app.run("dialog.newSmartAlbum", json!({})); } let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new(crate::i18n::tr("Clear")).font(t.font(12.5)))); - register(ui.ctx(), "button:filterClear", clear.rect); + register(ui, "button:filterClear", clear.rect); if clear.clicked() { app.ui.search.clear(); let _ = app.run("library.clearFilter", json!({})); } // filter presets: apply one, save the current filter, delete let presets = crate::widgets::dropdown(ui, "filterPresets", crate::i18n::tr("Presets"), t.font(12.5), t.text); - register(ui.ctx(), "button:filterPresets", presets.rect); + register(ui, "button:filterPresets", presets.rect); egui::Popup::menu(&presets).show(|ui| { let names: Vec = app.session.filter_presets.iter().map(|f| f.name.clone()).collect(); for n in &names { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 883f47f8b..262979cc5 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1,7 +1,6 @@ //! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails. #[cfg(test)] -#[path = "grid_header_tests.rs"] mod header_tests; use std::collections::{HashMap, HashSet}; @@ -309,10 +308,10 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let title = crate::widgets::elide_head(&full, room, |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text).size().x); let title_rect = ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, font, t.text); let count_rect = ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); - crate::widgets::register(ui.ctx(), "grid:title", title_rect); - crate::widgets::register(ui.ctx(), "grid:count", count_rect); + crate::widgets::register(ui, "grid:title", title_rect); + crate::widgets::register(ui, "grid:count", count_rect); if title != full { - ui.interact(title_rect, ui.id().with("grid-title"), Sense::hover()).on_hover_text(full); + ui.interact(title_rect, ui.scope_id().with("grid-title"), Sense::hover()).on_hover_text(full); } } } @@ -384,7 +383,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let active = app.session.selection.active; // bring the active photo into view when it changes (keyboard, click, command) or the grid // comes back on screen; otherwise the scroll position is the user's - let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active, app.session.source) { + let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::unique("grid-follow-active"), active, app.session.source) { active.and_then(|a| ids.iter().position(|x| *x == a)).and_then(|i| lay.cells.get(i).copied()) } else { None @@ -393,8 +392,8 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut visited = 0u64; // a wheel notch scrolls a third of the grid's height let scroll_rect = ui.available_rect_before_wrap(); - crate::widgets::register(ui.ctx(), "grid:scroll", scroll_rect); - let wheel = wheel_step(ui, egui::Id::new("grid-wheel"), scroll_rect, scroll_rect.height(), false); + crate::widgets::register(ui, "grid:scroll", scroll_rect); + let wheel = wheel_step(ui, egui::Id::unique("grid-wheel"), scroll_rect, scroll_rect.height(), false); egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { if wheel != egui::Vec2::ZERO { ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); @@ -656,10 +655,10 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: /// (Cmd/Shift: adds to the selection). fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: &[PhotoId], r: Rect, pinned: bool) { let t = Tokens::get(ui.ctx()); - let resp = ui.interact(r, egui::Id::new(("group-header", run.start, pinned)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("group-header", run.start, pinned)), Sense::click()); crate::widgets::announce(&resp, if run.key.is_empty() { crate::i18n::tr("Unknown date").to_string() } else { run.key.clone() }); if !pinned { - register(ui.ctx(), format!("group:{}", if run.key.is_empty() { "unknown" } else { &run.key }), r); + register(ui, format!("group:{}", if run.key.is_empty() { "unknown" } else { &run.key }), r); } let p = ui.painter(); if pinned { @@ -711,6 +710,15 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority { return; } + // Several cells can show one photo at different sizes (a print page's cells). Before any + // thumbnail arrives, a caller at another bucket would replace the pending request every frame + // and restart it, so none of them ever gets a picture: keep the request in flight. A higher + // priority caller may take it over once; the caller it replaced then coalesces behind it. + if let Some(photo) = app.session.catalog.photo(id) + && app.renderer.thumb_pending_other_bucket(photo, bucket, priority) + { + return; + } let Some(job) = app.session.thumb_job(id, size) else { return }; #[cfg(test)] { @@ -723,9 +731,9 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority quick.is_some() && app.session.catalog.photo(id).is_some_and(|p| skips_the_real_render(app.session.embedded_thumb_is_final(p), true)); if let Some(photo) = app.session.catalog.photo(id) { if embedded_is_final { - app.renderer.remember_embedded_thumb(photo, bucket, job.key); + app.renderer.remember_embedded_thumb(photo, bucket, job.key, priority); } else { - app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some()); + app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some(), priority); } } match quick { @@ -758,8 +766,8 @@ pub const BACKGROUND_THUMB_PRIORITY: u32 = 3; fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square: bool, onscreen: bool, ppp: f32) { let t = Tokens::get(ui.ctx()); let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; - let resp = ui.interact(r, egui::Id::new(("cell", id.0)), Sense::click_and_drag()); - register(ui.ctx(), format!("thumb:{}", id.0), r); + let resp = ui.interact(r, egui::Id::unique(("cell", id.0)), Sense::click_and_drag()); + 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 @@ -775,7 +783,7 @@ 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::WidgetType::SelectableLabel, true, selected, &spoken)); + 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 { @@ -900,8 +908,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square 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.ctx(), format!("badge:previewOnly:{}", id.0), br); - ui.interact(br, egui::Id::new(("preview-only-badge", id.0)), Sense::hover()) + 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 { @@ -962,7 +970,7 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engi folder_breadcrumbs(app, ui, &b.path); }); let count = ui.add_sized(vec2(count_w, 24.0), egui::Label::new(egui::RichText::new(cnt).size(12.5).color(t.text_dim)).truncate()); - register(ui.ctx(), "grid:count", count.rect); + register(ui, "grid:count", count.rect); count.on_hover_text(cnt); }); ui.horizontal_wrapped(|ui| { @@ -970,7 +978,7 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engi ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); let mut sub = b.subfolders; let c = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders")); - register(ui.ctx(), "check:includeSubfolders", c.rect); + register(ui, "check:includeSubfolders", c.rect); if c.changed() { let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub})); } @@ -979,7 +987,7 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engi let label = crate::i18n::tr_format!("Add {local_n} to My Photos", local_n = local_n); let button = ui.add(egui::Button::new(egui::RichText::new(label).font(t.semibold(11.5)).color(t.text)).wrap().min_size(vec2(37.0, 24.0))); - register(ui.ctx(), "button:addToLibrary", button.rect); + register(ui, "button:addToLibrary", button.rect); if button.clicked() { let local: Vec = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect(); let n = local.len(); @@ -991,7 +999,7 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engi } }); }); - register(ui.ctx(), "grid:folderHeader", header.response.rect); + register(ui, "grid:folderHeader", header.response.rect); header.response.rect } @@ -1042,7 +1050,7 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { let width = ((ui.available_width() - separator_w * (remaining - 1) as f32) / remaining as f32).max(0.0).floor(); let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label }); let r = ui.add_sized(vec2(width, 24.0), egui::Label::new(if last { text.strong() } else { text }).truncate().sense(Sense::click())); - register(ui.ctx(), format!("crumb:{i}"), r.rect); + register(ui, format!("crumb:{i}"), r.rect); if !last { if r.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); @@ -1064,7 +1072,7 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { /// drop would put them (set by the drop target under the pointer, read by [`drag_feedback`]). pub fn drag_hint(ctx: &egui::Context, text: String) { let frame = ctx.cumulative_frame_nr(); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag-photos-hint"), (frame, text))); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("drag-photos-hint"), (frame, text))); } pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { @@ -1080,14 +1088,17 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.set_cursor_icon(egui::CursorIcon::Grabbing); // a drop target under the pointer says where they would go (only this frame's) let frame = ctx.cumulative_frame_nr(); - let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text); + let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text); let text = hint.unwrap_or_else(|| crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)); - egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { - egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend()); - crate::widgets::register(ui.ctx(), "dragPhotos", label.rect); - }); - }); + egui::Area::new(egui::Id::unique("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend()); + crate::widgets::register(ui, "dragPhotos", label.rect); + }); + }, + ); } pub use super::filterbar::label_color; @@ -1141,13 +1152,13 @@ pub fn label_items(app: &mut LightcraftApp, ui: &mut egui::Ui, current: Option = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); let any = !albums.is_empty(); for (aid, name) in albums { let b = ui.button(name); - register(ui.ctx(), format!("button:contextAlbum:{aid}"), b.rect); + register(ui, format!("button:contextAlbum:{aid}"), b.rect); if b.clicked() { match app.run("album.addPhotos", json!({"id": aid})) { Ok(r) => toast_skipped(app, ui.ctx(), &r), @@ -1294,12 +1305,12 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // as in the Photo menu: a new album holding the selection, also when there is none yet // to pick (issue #676: the submenu came up empty) let new = ui.button(crate::i18n::tr("New Album…")); - register(ui.ctx(), "button:contextNewAlbum", new.rect); + register(ui, "button:contextNewAlbum", new.rect); if new.clicked() { let _ = app.run("dialog.newAlbum", json!({})); } }); - register(ui.ctx(), "button:contextAddToAlbum", add.response.rect); + register(ui, "button:contextAddToAlbum", add.response.rect); } // in a (non-smart) album: take the selection out of it, or make this photo its cover if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source @@ -1435,7 +1446,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } else { let delete = ui.button(crate::i18n::tr("Delete Photo")); - register(ui.ctx(), "button:contextDeletePhoto", delete.rect); + register(ui, "button:contextDeletePhoto", delete.rect); if delete.clicked() { let _ = crate::menubar::run_item(app, "photo.delete", json!({})); } diff --git a/crates/ui-egui/src/panels/grid_header_tests.rs b/crates/ui-egui/src/panels/grid/header_tests.rs similarity index 100% rename from crates/ui-egui/src/panels/grid_header_tests.rs rename to crates/ui-egui/src/panels/grid/header_tests.rs diff --git a/crates/ui-egui/src/panels/histogram.rs b/crates/ui-egui/src/panels/histogram.rs index e6c0b50c1..3f26b46bd 100644 --- a/crates/ui-egui/src/panels/histogram.rs +++ b/crates/ui-egui/src/panels/histogram.rs @@ -202,7 +202,7 @@ pub fn interact(app: &mut LightcraftApp, ui: &egui::Ui, resp: &Response, plot: R if dragging || hovered.is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - register(ui.ctx(), "histogram:zone", zone.rect(plot)); + register(ui, "histogram:zone", zone.rect(plot)); Some(Active { zone, value: value_of(app, id, spec), dragging }) } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 43190002a..ba31c6676 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -23,7 +23,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::widgets::divider(ui); // the title, where a dragged keyword goes back to the top level let (title, title_resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); - register(ui.ctx(), "keywordList:topLevel", title); + register(ui, "keywordList:topLevel", title); let dragging = app.ui.dragging_keyword.clone(); let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); let over_title = shown_under(ui, title, pointer); @@ -42,7 +42,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !open { return; } - let fid = egui::Id::new("keyword-list-filter"); + let fid = egui::Id::unique("keyword-list-filter"); let mut filter: String = ui.data(|d| d.get_temp(fid)).unwrap_or_default(); ui.horizontal(|ui| { ui.add_space(24.0); @@ -52,13 +52,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .show(ui); // + creates a keyword (inside the picked one), − deletes the picked one let plus = ui.button("+").on_hover_text(crate::i18n::tr("Create Keyword Tag")); - register(ui.ctx(), "keywordList:create", plus.rect); + register(ui, "keywordList:create", plus.rect); if plus.clicked() { app.ui.dialog = Some(create_dialog(app)); } let picked = app.ui.keyword_list_selected.clone().filter(|k| in_tree(&app.caches.keyword_tree(&app.session.catalog), k)); let minus = ui.add_enabled(picked.is_some(), egui::Button::new("−")).on_hover_text(crate::i18n::tr("Delete Keyword")); - register(ui.ctx(), "keywordList:delete", minus.rect); + register(ui, "keywordList:delete", minus.rect); if minus.clicked() && let Some(k) = picked { @@ -130,11 +130,14 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); ctx.set_cursor_icon(egui::CursorIcon::Grabbing); let name = keyword.rsplit('|').next().unwrap_or(&keyword).to_string(); - egui::Area::new(egui::Id::new("drag-keyword")).order(egui::Order::Tooltip).interactable(false).fixed_pos(at + vec2(14.0, 10.0)).show(ctx, |ui| { - egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label(egui::RichText::new(name).color(Color32::WHITE).font(t.semibold(12.0))); - }); - }); + egui::Area::new(egui::Id::unique("drag-keyword")).order(egui::Order::Tooltip).interactable(false).fixed_pos(at + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(name).color(Color32::WHITE).font(t.semibold(12.0))); + }); + }, + ); } /// Drop the dragged `keyword` inside `parent` (`None`: the top level). Where one of its name is @@ -167,7 +170,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); crate::widgets::announce(&resp, r.path.clone()); - register(ui.ctx(), format!("keywordRow:{}", r.path), rect); + register(ui, format!("keywordRow:{}", r.path), rect); if resp.drag_started() { app.ui.dragging_keyword = Some(r.path.clone()); } @@ -185,9 +188,9 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId if r.has_children { let c = pos2(x + 6.0, cy); let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); - let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); + let tr = ui.interact(tri, egui::Id::unique(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); crate::widgets::announce(&tr, crate::i18n::tr(if r.open { "Collapse" } else { "Expand" })); - register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri); + register(ui, format!("keywordRowToggle:{}", r.path), tri); // while a filter opens the levels the triangle does nothing: it doesn't light up either let lit = tr.hovered() && can_fold; paint(ui.painter(), tri, if r.open { Icon::ChevronDown } else { Icon::ChevronRight }, if lit { t.text } else { t.text_dim }); @@ -207,9 +210,9 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let on = !selection.is_empty(); // (it senses drags too, so a press there that moves doesn't pick the keyword up) let sense = if on { Sense::click_and_drag() } else { Sense::hover() }; - let tb = ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), sense); + let tb = ui.interact(boxr.expand(3.0), egui::Id::unique(("keyword-list-tick", r.path.to_lowercase())), sense); crate::widgets::announce(&tb, format!("{} {}", crate::i18n::tr("Keyword"), r.path)); - register(ui.ctx(), format!("keywordCheck:{}", r.path), boxr); + register(ui, format!("keywordCheck:{}", r.path), boxr); let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) }; match state { Tick::All => { @@ -229,7 +232,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId if !inside.is_empty() { let dot = Rect::from_center_size(boxr.center(), vec2(5.0, 5.0)); ui.painter().circle_filled(dot.center(), 2.5, t.text); - register(ui.ctx(), format!("keywordInside:{}", r.path), dot); + register(ui, format!("keywordInside:{}", r.path), dot); } } } @@ -254,12 +257,12 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId // the count, then the arrow left of it (shown on hover) let count = ui.painter().layout_no_wrap(r.count.to_string(), t.font(12.0), t.text_dim); let count_rect = Rect::from_min_size(pos2(inner.right() - 6.0 - count.size().x, cy - count.size().y / 2.0), count.size()); - register(ui.ctx(), format!("keywordCount:{}", r.path), count_rect); + register(ui, format!("keywordCount:{}", r.path), count_rect); ui.painter().galley(count_rect.min, count, t.text_dim); let arrow = Rect::from_center_size(pos2(count_rect.left() - 12.0, cy), vec2(16.0, 16.0)); - let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click()); + let ar = ui.interact(arrow, egui::Id::unique(("keyword-list-show", r.path.to_lowercase())), Sense::click()); crate::widgets::announce(&ar, crate::i18n::tr("Show photos")); - register(ui.ctx(), format!("keywordShow:{}", r.path), arrow); + register(ui, format!("keywordShow:{}", r.path), arrow); if resp.hovered() || ar.hovered() { paint(ui.painter(), arrow, Icon::ChevronRight, if ar.hovered() { t.text } else { t.text_dim }); } @@ -277,7 +280,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId if app.session.catalog.default_keyword_parent().as_deref().is_some_and(|k| same(k, &r.path)) { let dot = Rect::from_center_size(pos2(label.right() + 7.0, cy), vec2(6.0, 6.0)); ui.painter().circle_filled(dot.center(), 3.0, t.accent); - register(ui.ctx(), format!("keywordDefault:{}", r.path), dot); + register(ui, format!("keywordDefault:{}", r.path), dot); } let resp = resp.on_hover_text(r.path.replace('|', " › ")); if resp.clicked() { @@ -351,7 +354,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[Pho let name = path.replace('|', " › "); let item = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool| { let r = ui.add_enabled(enabled, egui::Button::new(label)); - register(ui.ctx(), format!("keywordMenu:{id}"), r.rect); + register(ui, format!("keywordMenu:{id}"), r.rect); r.clicked() }; if item(ui, "create", &crate::i18n::tr_format!("Create Keyword Tag Inside “{name}”…", name = name), true) { diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index a6f53562d..4861d4c6f 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -18,7 +18,7 @@ pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { [(V::Keywords, "keywords", "Keywords"), (V::Containing, "containing", "& Containing"), (V::WillExport, "willExport", "Will Export")] { let r = ui.selectable_label(app.ui.keywording_view == view, crate::i18n::tr(label)); - register(ui.ctx(), format!("keywordView:{id}"), r.rect); + register(ui, format!("keywordView:{id}"), r.rect); if r.clicked() { app.ui.keywording_view = view; } @@ -41,7 +41,7 @@ pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { for n in names.iter() { let (body, _) = chip(ui, &n.path, &n.path, !n.on_all(), false, ui.max_rect().width()); - register(ui.ctx(), format!("{prefix}:{}", n.path), body.rect); + register(ui, format!("{prefix}:{}", n.path), body.rect); if n.on_all() { body.on_hover_text(&n.path); } else { @@ -87,17 +87,17 @@ fn chip(ui: &mut egui::Ui, path: &str, name: &str, partial: bool, cross: bool, r // the row places it; its name and × answer under ids of their own keyword, so an open menu // stays with its keyword when the chips change let (rect, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); - let id = egui::Id::new(("keyword-chip", path.to_lowercase(), cross)); + let id = egui::Id::unique(("keyword-chip", path.to_lowercase(), cross)); let body = Rect::from_min_max(rect.min, pos2(rect.right() - cross_w, rect.bottom())); let full = path.replace('|', " › "); let resp = ui.interact(body, id, Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &full)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &full)); // the ×: the whole end of the chip, so that it is hard to miss and easy to tell from the name let x = cross.then(|| { let xr = Rect::from_min_max(pos2(rect.right() - cross_w, rect.top()), rect.max); let xresp = ui.interact(xr, id.with("remove"), Sense::click()); let said = crate::i18n::tr_format!("Remove “{name}”", name = full); - xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &said)); + xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &said)); xresp }); let p = ui.painter(); @@ -148,7 +148,7 @@ fn remove(app: &mut LightcraftApp, ctx: &egui::Context, chip: &Chip) { fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { let item = |ui: &mut egui::Ui, id: &str, label: &str| { let r = ui.button(label); - register(ui.ctx(), format!("keywordChipMenu:{id}"), r.rect); + register(ui, format!("keywordChipMenu:{id}"), r.rect); r.clicked() }; if !chip.on_all() && item(ui, "add", crate::i18n::tr("Add to All Selected Photos")) { @@ -177,7 +177,7 @@ pub fn scope(app: &mut LightcraftApp, ui: &mut egui::Ui, selection: &[PhotoId]) let t = Tokens::get(ui.ctx()); let said = crate::i18n::tr_format!("Keywords of {n} selected photos", n = photos); let r = ui.label(egui::RichText::new(said).size(11.5).color(t.text_label)); - register(ui.ctx(), "keywordScope", r.rect); + register(ui, "keywordScope", r.rect); ui.add_space(4.0); } @@ -189,7 +189,7 @@ pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui, selection: &[PhotoId if chips.is_empty() { let t = Tokens::get(ui.ctx()); let r = ui.label(egui::RichText::new(crate::i18n::tr("No keywords yet")).color(t.text_dim)); - register(ui.ctx(), "keywordChipsEmpty", r.rect); + register(ui, "keywordChipsEmpty", r.rect); return; } if app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped { @@ -208,9 +208,9 @@ pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui, selection: &[PhotoId /// A drawn chip does its work: its × takes the keyword off the selected photos, its name has the /// tooltip and the menu. fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Response, x: Option) { - register(ui.ctx(), format!("keywordChip:{}", c.path), body.rect); + register(ui, format!("keywordChip:{}", c.path), body.rect); if let Some(x) = x { - register(ui.ctx(), format!("keywordChipRemove:{}", c.path), x.rect); + register(ui, format!("keywordChipRemove:{}", c.path), x.rect); if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { remove(app, ui.ctx(), c); } @@ -220,7 +220,7 @@ fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Respo let body = if c.on_all() { body.on_hover_text(full) } else { - register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect); + register(ui, format!("keywordChipPartial:{}", c.path), body.rect); let n = crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of); body.on_hover_text(format!("{full}\n{n}")) }; @@ -273,13 +273,13 @@ fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group], sel let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), small.clone(), t.text_label).size().x; let shown = chip_label(&group.parent, false, avail, measure); let full = group.parent.replace('|', " › "); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &full)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &full)); ui.painter().text(r.left_center(), Align2::LEFT_CENTER, &shown, small.clone(), t.text_label); // (cut short, it says its whole name on hover, as a chip does) if shown != full { resp.on_hover_text(&full); } - register(ui.ctx(), format!("keywordGroup:{}", group.parent), r); + register(ui, format!("keywordGroup:{}", group.parent), r); } Line::Chips(g, row) => { let Some(group) = groups.get(g) else { continue }; @@ -296,7 +296,7 @@ fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group], sel ui.add_space(6.0); let label = crate::i18n::tr_format!("+{n} more", n = n); let r = ui.add(egui::Button::new(egui::RichText::new(label).color(t.text_label)).corner_radius(10.0)); - register(ui.ctx(), "keywordChipsMore", r.rect); + register(ui, "keywordChipsMore", r.rect); if r.on_hover_text(crate::i18n::tr("Show every keyword of the selected photos")).clicked() { app.ui.keyword_chips_all = Some(selection); } @@ -304,7 +304,7 @@ fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group], sel Line::Fewer => { ui.add_space(6.0); let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Show fewer")).color(t.text_label)).corner_radius(10.0)); - register(ui.ctx(), "keywordChipsFewer", r.rect); + register(ui, "keywordChipsFewer", r.rect); if r.clicked() { app.ui.keyword_chips_all = None; } @@ -434,7 +434,7 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at let lit = at.and_then(|k| offers.keywords.iter().position(|x| x == k)); let first = lit.map_or(0, |a| a.saturating_add(1).saturating_sub(count)).min(offers.keywords.len().saturating_sub(count)); let listed = offers.keywords.iter().skip(first).take(count); - let area = egui::Area::new(egui::Id::new("keyword-offers")).order(egui::Order::Foreground).constrain(true); + let area = egui::Area::new(egui::Id::unique("keyword-offers")).order(egui::Order::Foreground).constrain(true); let area = if over { area.pivot(Align2::LEFT_BOTTOM).fixed_pos(below.left_top() - vec2(0.0, 2.0)) } else { @@ -448,7 +448,7 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at let line = |ui: &mut egui::Ui, id: egui::Id, name: &str, place: &str, said: &str, lit: bool| { let (r, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); let resp = ui.interact(r, id, Sense::click()).union(resp); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, lit, said)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, lit, said)); if lit || resp.hovered() { ui.painter().rect_filled(r, 4.0, if lit { t.pressed } else { t.hover }); } @@ -469,10 +469,10 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at let (place, name) = k.rsplit_once(SEP).unwrap_or(("", k)); let whole = k.replace(SEP, " › "); let lit = at == Some(k.as_str()); - let r = line(ui, egui::Id::new(("keyword-offer", k.to_lowercase())), name, place, &whole, lit); - register(ui.ctx(), format!("kwComplete:{k}"), r.rect); + let r = line(ui, egui::Id::unique(("keyword-offer", k.to_lowercase())), name, place, &whole, lit); + register(ui, format!("kwComplete:{k}"), r.rect); if lit { - register(ui.ctx(), format!("kwCompleteAt:{k}"), r.rect); + register(ui, format!("kwCompleteAt:{k}"), r.rect); } if r.clicked() { picked = Some(Picked::Keyword(k.clone())); @@ -497,10 +497,10 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at ) }; // (its place is said in words: not a path to cut from the front) - let r = line(ui, egui::Id::new("keyword-offer-create"), name, "", &said, false); + let r = line(ui, egui::Id::unique("keyword-offer-create"), name, "", &said, false); let small = t.font(11.5); ui.painter().text(pos2(r.rect.right() - 8.0, r.rect.center().y), Align2::RIGHT_CENTER, place, small, t.text_label); - register(ui.ctx(), "kwCompleteCreate", r.rect); + register(ui, "kwCompleteCreate", r.rect); // (Return gives the highlighted offer instead, while there is one) let r = if at.is_none() { r.on_hover_text(crate::i18n::tr("Return creates this keyword and gives it to the selected photos")) diff --git a/crates/ui-egui/src/panels/layout_overlay.rs b/crates/ui-egui/src/panels/layout_overlay.rs index 9b4800d0f..655e2ae33 100644 --- a/crates/ui-egui/src/panels/layout_overlay.rs +++ b/crates/ui-egui/src/panels/layout_overlay.rs @@ -16,7 +16,7 @@ use lightcraft_raster::Rgba8; use crate::LightcraftApp; use crate::theme::Tokens; -use crate::widgets::register; +use crate::widgets::register_geometry; /// The longest side a layout image keeps (a screen never shows more of it). pub const MAX_SIDE: usize = 4096; @@ -112,7 +112,7 @@ pub fn draw(app: &LightcraftApp, p: &egui::Painter, photo: Rect) { return; } let ctx = p.ctx().clone(); - let id = egui::Id::new("loupe-layout-image"); + let id = egui::Id::unique("loupe-layout-image"); let file = (path.to_string(), li.revision); let mut cache = match ctx.data(|d| d.get_temp::(id)) { Some(c) if c.file == file => c, @@ -153,7 +153,7 @@ pub fn draw(app: &LightcraftApp, p: &egui::Painter, photo: Rect) { } let alpha = (li.opacity.clamp(0.0, 1.0) * 255.0).round() as u8; p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(alpha)); - register(&ctx, "canvas:layoutImage", r.intersect(p.clip_rect())); + register_geometry(&ctx, "canvas:layoutImage", r.intersect(p.clip_rect())); } ctx.data_mut(|d| d.insert_temp(id, cache)); } @@ -173,7 +173,7 @@ fn note(p: &egui::Painter, why: &str) { p.galley(at, g, Color32::WHITE); y -= 3.0; } - register(p.ctx(), "canvas:layoutImageError", Rect::from_min_max(pos2(canvas.left(), y), pos2(canvas.left() + 360.0, canvas.bottom()))); + register_geometry(p.ctx(), "canvas:layoutImageError", Rect::from_min_max(pos2(canvas.left(), y), pos2(canvas.left() + 360.0, canvas.bottom()))); } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 2c7d2642c..522afe084 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -11,634 +11,35 @@ use crate::sidebar::SidebarSection; use crate::theme::Tokens; use crate::widgets::{icon_button, register}; -/// 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 -/// exceeds the panel. One frame behind, which needs no second layout pass. -pub(crate) fn content_width(ctx: &egui::Context) -> f32 { - ctx.data(|d| d.get_temp::(egui::Id::new("left-content-width"))).unwrap_or(0.0) -} +mod albums; +mod dates; +mod folders; +mod keywords; +mod local; +mod rows; +mod sections; -/// The most a row asks for its name: longer ones are cut when there is no more room. -const MAX_NAME_NEED: f32 = 140.0; - -/// Height of a sidebar row. -const ROW_HEIGHT: f32 = 29.0; - -/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to -/// this frame: set before the sections are drawn, cleared after. -const SCROLL_TO_ROW: &str = "left-scroll-to-row"; - -/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]): -/// removed, they open again. -const LIBFOLDER_REVEALED: &str = "libfolder-revealed"; - -/// A row says how wide it needs to be. -fn note_width(ui: &egui::Ui, w: f32) { - ui.data_mut(|d| { - let m = d.get_temp_mut_or_insert_with::(egui::Id::new("left-content-width-next"), || 0.0); - *m = m.max(w); - }); -} - -/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps. -pub(crate) const INDENT_STEP: f32 = 16.0; - -/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's. -pub(crate) const MIN_INDENT_STEP: f32 = 8.0; - -/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to -/// less than this while a narrower step would still keep it. -const MIN_NAME_ROOM: f32 = 80.0; - -/// Room kept for a count at the end of the deepest row (three digits and their gap). -const COUNT_ROOM: f32 = 40.0; - -/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the -/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame. -pub(crate) fn indent_step(ctx: &egui::Context) -> f32 { - ctx.data(|d| d.get_temp::(egui::Id::new("left-indent-step"))).unwrap_or(INDENT_STEP) -} - -/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame. -fn indent_x(ctx: &egui::Context, indent: f32) -> f32 { - indent / INDENT_STEP * indent_step(ctx) -} - -/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next -/// frame's step. -fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 { - ui.data_mut(|d| { - let m = d.get_temp_mut_or_insert_with::(egui::Id::new("left-deepest-indent-next"), || 0.0); - *m = m.max(indent); - }); - indent_x(ui.ctx(), indent) -} - -/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s) -/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a -/// row does not slide sideways at every point the panel is dragged; when even the narrowest step -/// does not fit, the sidebar scrolls sideways as before. -pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { - let levels = deepest / INDENT_STEP; - if levels < 1.0 { - return INDENT_STEP; - } - let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM; - [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) -} - -fn row( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &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)] -fn row_sensed( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, - count: Option, - selected: bool, - indent: f32, - sense: Sense, - mark: Option, -) -> egui::Response { - let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; - let t = Tokens::get(ui.ctx()); - let indent = drawn_indent(ui, indent); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); - if scroll_asked(ui, id) { - ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); - } - register(ui.ctx(), format!("source:{id}"), r); - let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; - let said = spoken.unwrap_or(label); - let name = match count { - Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n), - None => said.to_string(), - }; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)); - // what is visible of the row: when the sidebar is scrolled sideways its bars and counts end at - // the panel's edge, not at the end of the (wider) content - let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX); - let edge = r.right().min(visible_right); - let inner = Rect::from_min_max(r.min + vec2(8.0, 0.0), pos2((r.right() - 8.0).min(edge - 8.0).max(r.left() + 8.0), r.bottom())); - if selected { - ui.painter().rect_filled(inner, 4.0, t.canvas); - register(ui.ctx(), format!("highlight:{id}"), inner); - } else if resp.hovered() { - ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6)); - } - paint( - ui.painter(), - Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)), - icon, - if selected { t.text } else { t.icon }, - ); - let font = t.font(13.5); - let color = if selected { t.text } else { t.text_label }; - // a count of nothing is there, but fainter: the rows that hold photos stand out - let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim }; - let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n))); - let label_left = r.left() + 42.0 + indent; - let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); - // a label's dot sits just before the count - if let Some(c) = mark { - let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0)); - ui.painter().circle_filled(dot.center(), 4.0, c); - register(ui.ctx(), format!("labelMark:{id}"), dot); - } - // the name gives way to the count: cut with an ellipsis, in full on hover - let room = count_left - mark_w - 8.0 - label_left; - let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; - let full_w = measure(label); - let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) }; - let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); - register(ui.ctx(), format!("label:{id}"), label_rect); - let resp = if shown != label { resp.on_hover_text(label) } else { resp }; - note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x))); - if let Some(galley) = count_galley { - let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size()); - register(ui.ctx(), format!("count:{id}"), rect); - ui.painter().galley(rect.min, galley, t.text_dim); - } - resp -} - -/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed -/// for the control channel and asks for its width (so the content does not widen and narrow as -/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders, -/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is -/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`]. -fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option, indent: f32, mark: bool) -> bool { - let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); - if ui.is_rect_visible(r) || scroll_asked(ui, id) { - return false; - } - ui.allocate_space(r.size()); - register(ui.ctx(), format!("source:{id}"), r); - let indent = drawn_indent(ui, indent); - let t = Tokens::get(ui.ctx()); - let painter = ui.painter(); - let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; - let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x); - note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w)); - true -} - -/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]). -fn scroll_asked(ui: &egui::Ui, id: &str) -> bool { - ui.data(|d| d.get_temp::(egui::Id::new(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase()) -} - -/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for -/// room for its name up to a share of a panel, so one very long name does not make everything -/// scroll: depth does that. -fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 { - 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) -} - -/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing -/// when closed, down when open, registered as widget `widget` for the control channel. Returns -/// its response: the caller flips the open state on `clicked()`. -fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y); - let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); - let tr = ui.interact(tri, id, Sense::click()); - register(ui.ctx(), widget, tri); - let col = if tr.hovered() { t.text } else { t.text_dim }; - paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); - tr -} - -/// Whether the tree row `key` is open: what the user last chose this session, else `default`. -fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool { - ui.data(|d| d.get_temp(key)).unwrap_or(default) -} - -/// Open or close the tree row `key` from now on. -fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { - ui.data_mut(|d| d.insert_temp(key, open)); -} - -/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, -/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response -/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). -fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { - let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget); - if tri.clicked() { - *open = !*open; - set_tree_open(ui, key, *open); - } - let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }; - tri.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); - tri -} - -/// Space above a section's header. -const SECTION_GAP: f32 = 10.0; -/// Height of a section's header. -const SECTION_HEADER: f32 = 34.0; - -/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` -/// draws. Every section goes through here, so they all fold, space and announce themselves alike. -/// `rows`: how many rows the section lists at its top level (a folded header says so). -fn section( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - section: SidebarSection, - rows: usize, - body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), -) -> PlacedSection { - ui.add_space(SECTION_GAP); - let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); - // a hairline in the gap above tells the sections apart - let t = Tokens::get(ui.ctx()); - let edge = visible_right(ui).min(header.right()); - let y = header.top() - SECTION_GAP / 2.0; - ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); - let show = egui::Id::new("left-show-section"); - if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show)) - && asked == section - { - ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show)); - // asked for by the frame before (a request the section was not drawn for is forgotten), - // and up or down only: the sidebar stays where it was scrolled sideways - if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 { - let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-view-top"))).unwrap_or(header.top()); - let delta = vec2(0.0, view_top - header.top()); - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - } - let open = section_header(app, ui, section, header, rows, false).open; - if open { - let rows_from = ui.cursor().top(); - body(app, ui); - if ui.cursor().top() <= rows_from { - empty_hint(ui, section); - } - } - PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows } -} - -/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider -/// than the panel while the sidebar scrolls sideways. -fn visible_right(ui: &egui::Ui) -> f32 { - ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX) -} - -/// The header of the open section whose own header has scrolled out of sight stays at the top of -/// the sidebar (widget `sidebarSectionPinned:`), so it is always clear which section the rows -/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The -/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. -fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { - let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top); - let rect = pinned.map(|p| { - let top = view_top.min(p.bottom - SECTION_HEADER); - Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER) - }); - // rows ask where it is (next frame: they are laid out before it) - ui.data_mut(|d| d.insert_temp(egui::Id::new("left-pinned-rect"), rect)); - let (Some(p), Some(r)) = (pinned, rect) else { return }; - let t = Tokens::get(ui.ctx()); - let view = ui.clip_rect(); - // over the rows scrolling beneath it - ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome); - ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider)); - if section_header(app, ui, p.section, r, p.rows, true).toggled { - // folded from here: its own header comes into view, not whatever was below the section - // (next frame, when the section is laid out folded: see `section`) - let frame = ui.ctx().cumulative_frame_nr(); - ui.data_mut(|d| d.insert_temp(egui::Id::new("left-show-section"), (p.section, frame))); - ui.ctx().request_repaint(); - } -} - -/// What an open section with nothing to list says in place of its rows (widget -/// `sidebarEmpty:`), so the section stays where it is instead of disappearing. -fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover()); - register(ui.ctx(), format!("sidebarEmpty:{}", section.id()), r); - let text = crate::i18n::tr(section.empty_hint()); - let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); - note_width(ui, 42.0 + galley.size().x + 18.0); - ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim); -} - -/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct PlacedSection { - pub section: SidebarSection, - pub top: f32, - pub bottom: f32, - /// Not folded: its rows (or its note that there are none) are drawn. - pub open: bool, - /// How many rows it lists at its top level. - pub rows: usize, -} - -/// What a section's header says happened to it this frame. -struct HeaderOutcome { - open: bool, - /// A click folded or unfolded the section. - toggled: bool, -} - -/// Where a section dragged to `y` would go among the sections drawn: above the first one whose -/// middle is below `y` (`Some`), or after them all (`None`). -pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option { - placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section) -} - -/// While a section is dragged by its header: a line shows where it would go, releasing over the -/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it. -fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) { - let Some(dragged) = app.ui.dragging_section else { return }; - let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); - if esc { - // the drag's Esc, not also a dialog's or a view's - crate::widgets::take_escape(ui.ctx()); - app.ui.dragging_section = None; - return; - } - let view = ui.clip_rect(); - let Some(pos) = pos.filter(|p| view.contains(*p)) else { - if released { - app.ui.dragging_section = None; - } - return; - }; - let before = section_drop_before(placed, pos.y); - if released { - app.ui.dragging_section = None; - let before = before.map_or(serde_json::Value::Null, |b| json!(b.id())); - if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) { - app.toast(ui.ctx(), e); - } - return; - } - // the line: in the gap above the section it would go before, or below the last one - let y = match before { - Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0), - None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0), - }; - if let Some(y) = y { - let t = Tokens::get(ui.ctx()); - let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0)); - ui.painter().rect_filled(line, 1.0, t.accent); - register(ui.ctx(), "sidebarSectionDrop", line); - } -} - -/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the -/// sidebar, or with the sidebar closed) is over. -pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(section) = app.ui.dragging_section else { return }; - let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); - if !down || !app.ui.left_panel { - app.ui.dragging_section = None; - return; - } - let Some(pos) = pos else { return }; - let t = Tokens::get(ctx); - ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - egui::Area::new(egui::Id::new("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( - ctx, - |ui| { - egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0))); - }); - }, - ); -} - -/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their -/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so -/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at -/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo -/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). -fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { - let t = Tokens::get(ui.ctx()); - let id = section.id(); - let title = crate::i18n::tr(section.title()); - let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") }; - let resp = ui.interact(r, ui.id().with((interact, id)), Sense::click_and_drag()); - register(ui.ctx(), format!("{widget}:{id}"), r); - let toggled = resp.clicked(); - if toggled { - app.ui.sidebar.toggle_collapsed(section); - } - // dragged by its header, a section moves among the others (see `section_drag`); a press on - // the header's button is the button's, also when it turns into a drag - let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p)); - if resp.drag_started_by(egui::PointerButton::Primary) && !on_button { - // picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`) - ui.data_mut(|d| d.insert_temp(egui::Id::new("left-section-drag-from-pinned"), pinned)); - app.ui.dragging_section = Some(section); - } - crate::widgets::context_menu(&resp, |ui| sections_menu(app, ui, Some(section))); - let open = !app.ui.sidebar.is_collapsed(section); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); - // what is visible of the header: its bar and count end at the panel's edge, like a row's - let edge = r.right().min(visible_right(ui)); - let hovered = resp.hovered() && app.ui.dragging_section.is_none(); - if hovered { - let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0)); - ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); - } - let c = pos2(r.left() + 10.0, r.center().y); - register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0))); - let col = if hovered { t.text } else { t.text_dim }; - paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); - ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); - let button = header_button(section); - // (an album being dragged puts "Top Level" in the Albums header) - let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some()); - if !open && rows > 0 && app.ui.show_counts && room { - // folded: how much is in there (left of the header's own button) - let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 }; - let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); - register(ui.ctx(), format!("{widget}Count:{id}"), count); - } - if section == SidebarSection::Albums { - top_level_drop_target(app, ui, r); - } - if let Some(button) = button { - show_header_button(app, ui, r, &button); - } - HeaderOutcome { open, toggled } -} - -/// Side of a header's button. -const HEADER_BUTTON: f32 = 26.0; - -/// A button at the end of a section's header (see [`header_button`]). -struct HeaderButton { - /// Its widget is `icon:`. - id: &'static str, - icon: Icon, - /// What it does, in English: its tooltip and spoken name. - tooltip: &'static str, - action: HeaderAction, -} - -/// What a header's button does when clicked. -enum HeaderAction { - /// Does it at once (usually: opens a dialog). - Run(fn(&mut LightcraftApp, &egui::Context)), - /// Opens a menu with these items. - Menu(fn(&mut LightcraftApp, &mut egui::Ui)), -} - -/// The button a section has at the end of its header, if it has one. This is the one place that -/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps -/// its clicks from folding the section and leaves it room beside a folded section's count. -fn header_button(section: SidebarSection) -> Option { - match section { - SidebarSection::Albums => { - Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) }) - } - SidebarSection::Keywords => { - Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) }) - } - SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None, - } -} - -/// The end of `header` kept for its button: at the visible edge, also when the sidebar is -/// scrolled sideways. -fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect { - let right = visible_right(ui).min(header.right()); - Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom())) -} - -/// Draw `button` at the end of `header` and act on it. -fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) { - let mut hdr = - ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).layout(egui::Layout::right_to_left(egui::Align::Center))); - let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip); - match button.action { - HeaderAction::Run(run) => { - if resp.clicked() { - run(app, ui.ctx()); - } - } - HeaderAction::Menu(items) => { - egui::Popup::menu(&resp).show(|ui| items(app, ui)); - } - } -} - -/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside -/// whatever is picked in the Keyword List, which may not even be on screen). -/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`). -fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) { - let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None); - if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog { - *reveal = true; - } - app.ui.dialog = Some(dialog); -} - -/// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, -/// show or hide any section, and go back to the usual arrangement. Its items are widgets -/// `sidebarMenu:hide`, `sidebarMenu:section:` and `sidebarMenu:reset`. -fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option) { - let mut run = None; - if let Some(section) = on { - let hide = ui.button(crate::i18n::tr("Hide This Section")); - register(ui.ctx(), "sidebarMenu:hide", hide.rect); - if hide.clicked() { - run = Some(("view.sidebarSection", json!({"section": section.id(), "show": false}))); - } - for (up, label, widget) in [(true, "Move Up", "sidebarMenu:moveUp"), (false, "Move Down", "sidebarMenu:moveDown")] { - let item = ui.add_enabled(app.ui.sidebar.can_move(section, up), egui::Button::new(crate::i18n::tr(label))); - register(ui.ctx(), widget, item.rect); - if item.clicked() { - run = Some(("view.sidebarMoveSection", json!({"section": section.id(), "direction": if up { "up" } else { "down" }}))); - } - } - ui.separator(); - } - for s in app.ui.sidebar.sections().iter().filter(|s| s.id.available()) { - let shown = ui.selectable_label(!s.hidden, crate::i18n::tr(s.id.title())); - register(ui.ctx(), format!("sidebarMenu:section:{}", s.id.id()), shown.rect); - if shown.clicked() { - run = Some(("view.sidebarSection", json!({"section": s.id.id(), "show": s.hidden}))); - } - } - ui.separator(); - let reset = ui.add_enabled(!app.ui.sidebar.is_default(), egui::Button::new(crate::i18n::tr("Reset Sidebar Sections"))); - register(ui.ctx(), "sidebarMenu:reset", reset.rect); - if reset.clicked() { - run = Some(("view.sidebarReset", json!({}))); - } - if let Some((command, params)) = run { - if let Err(e) = app.run(command, params) { - app.toast(ui.ctx(), e); - } - ui.close(); - } -} - -/// The menu of Albums' +: Create Album, Smart Album, Folder… -fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { - if ui.button(crate::i18n::tr("Create Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }); - } - if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::SmartRules { - id: None, - name: String::new(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: None, - }); - } - if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); - } - if ui.button(crate::i18n::tr("Create Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None, include_selected: false }); - } - // only once the albums were put in an order by hand - if app.session.catalog.album_children_are_ordered(None) { - ui.separator(); - if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { - let _ = app.run("album.sort", json!({})); - } - } -} +pub use albums::{album_drag_feedback, auto_scroll_speed}; +use albums::{albums_rows, drag_auto_scroll}; +pub(crate) use dates::browse_all_photos; +use dates::date_rows; +pub(crate) use folders::{folder_row_of, move_photos, moved_text}; +use folders::{folder_rows, reveal_chosen}; +use keywords::keyword_rows; +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}; +pub(crate) use rows::{content_width, indent_step, step_for}; +pub(crate) use sections::PlacedSection; +pub use sections::section_drag_feedback; +#[cfg(test)] +pub(crate) use sections::section_drop_before; +use sections::{SECTION_GAP, missing_count, pinned_header, section, section_drag, sections_menu}; +/// Draw the My Photos sidebar: title and open-library name, the scrollable sources, albums, +/// folders and sections, with the section visibility menu on the title. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); @@ -647,7 +48,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the title offers the sections' menu too: where hidden sections come back from let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); crate::widgets::announce(&title, crate::i18n::tr("My Photos")); - register(ui.ctx(), "sidebarTitle", hr); + register(ui, "sidebarTitle", hr); crate::widgets::context_menu(&title, |ui| sections_menu(app, ui, None)); // the open library's name under the title, so nobody edits in the wrong one (issue #902) let library = crate::panels::settings::open_library_name(app); @@ -661,7 +62,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { t.font(11.0), t.text_dim, ); - crate::widgets::register(ui.ctx(), "label:libraryName", hr); + crate::widgets::register(ui, "label:libraryName", hr); if let Some(l) = app.session.library.as_ref() { title.on_hover_text(l.dir.display().to_string()); } @@ -672,16 +73,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { drag_auto_scroll(app, ui); // where the visible part of the content starts (screen y), for the pinned header let view_top = ui.cursor().top() + viewport.min.y; - ui.data_mut(|d| d.insert_temp(egui::Id::new("left-view-top"), view_top)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-view-top"), view_top)); // rows are as wide as the widest one needs (last frame), at least the panel let wide = viewport.width().max(content_width(ui.ctx())); ui.set_min_width(wide); ui.set_max_width(wide); ui.data_mut(|d| { - d.insert_temp(egui::Id::new("left-content-width-next"), 0.0f32); - d.insert_temp(egui::Id::new("left-deepest-indent-next"), 0.0f32); + d.insert_temp(egui::Id::unique("left-content-width-next"), 0.0f32); + d.insert_temp(egui::Id::unique("left-deepest-indent-next"), 0.0f32); // where the visible part of the content ends (screen x), for what stays at the edge - d.insert_temp(egui::Id::new("left-visible-right"), ui.cursor().left() + viewport.max.x); + d.insert_temp(egui::Id::unique("left-visible-right"), ui.cursor().left() + viewport.max.x); }); let src = app.session.source; for (id, icon, label, count, s) in [ @@ -719,18 +120,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { above.push('|'); } above.push_str(name); - set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true); + set_tree_open(ui, egui::Id::unique(("kw-open", above.to_lowercase())), true); } // …and the sidebar scrolls to it - ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase())); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase())); } // the folder Show in Folders went to: Folders is shown and opened, and so are the // folders above it, also when it was already the one shown and one was folded by hand… if let Some(path) = app.ui.reveal_folder.take() { app.ui.sidebar.reveal(SidebarSection::Folders); - ui.data_mut(|d| d.remove::>(egui::Id::new(LIBFOLDER_REVEALED))); + ui.data_mut(|d| d.remove::>(egui::Id::unique(LIBFOLDER_REVEALED))); // …and the sidebar scrolls to it - ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase())); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase())); } // Local keeps track of the folder being browsed whether or not it is drawn let local = local_now(app, ui); @@ -764,1628 +165,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; placed.push(at); } - ui.data_mut(|d| d.remove::(egui::Id::new(SCROLL_TO_ROW))); + ui.data_mut(|d| d.remove::(egui::Id::unique(SCROLL_TO_ROW))); // room below the last section (a section dragged to the end is dropped here) ui.add_space(SECTION_GAP * 2.0); pinned_header(app, ui, &placed, view_top); section_drag(app, ui, &placed); // what the rows asked for becomes next frame's width - let next = ui.data(|d| d.get_temp::(egui::Id::new("left-content-width-next"))).unwrap_or(0.0); + let next = ui.data(|d| d.get_temp::(egui::Id::unique("left-content-width-next"))).unwrap_or(0.0); if (next - content_width(ui.ctx())).abs() > 0.5 { - ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width"), next)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-content-width"), next)); ui.ctx().request_repaint(); } // …and the deepest row drawn (or passed over out of sight) sets next frame's step - let deepest = ui.data(|d| d.get_temp::(egui::Id::new("left-deepest-indent-next"))).unwrap_or(0.0); + let deepest = ui.data(|d| d.get_temp::(egui::Id::unique("left-deepest-indent-next"))).unwrap_or(0.0); let step = step_for(viewport.width(), deepest); if step != indent_step(ui.ctx()) { - ui.data_mut(|d| d.insert_temp(egui::Id::new("left-indent-step"), step)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-indent-step"), step)); ui.ctx().request_repaint(); } }); }); } - -/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, -/// whose folders open down to it. -fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option) { - let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); - let cat = &app.session.catalog; - let mut open_to = Vec::new(); - let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); - while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { - open_to.push(p); - cur = cat.album(p).and_then(|a| a.parent); - } - albums_tree(app, ui, &kids, None, 0.0, &open_to); -} - -/// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. -fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let groups = app.caches.date_groups(&app.session.catalog); - for g in groups.iter() { - if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { - for (m, n) in &g.months { - let label = crate::i18n::date_group_label(m, true); - if date_row(app, ui, m, &label, *n, INDENT_STEP) { - for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = crate::i18n::date_group_label(d, true); - date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP); - } - } - } - } - } -} - -/// How many library photos have no file (Local browse records are not checked; see -/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes -/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed -/// at most every 5 s, and at once (after the running check) when the catalog changed. -fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; - #[derive(Clone, Default)] - struct Job { - n: std::sync::Arc, - running: std::sync::Arc, - at: f64, - rev: u64, - } - let id = egui::Id::new("missing-count"); - let now = ui.input(|i| i.time); - let rev = app.session.catalog.revision; - let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() }); - let fresh = now - job.at < 5.0 && job.rev == rev; - if !fresh && !job.running.load(Relaxed) { - // the same scope as the Missing Photos view: library photos only, never Local browse records - let paths = lightcraft_engine::cmd::missing::candidates(&app.session.catalog); - job.running.store(true, Relaxed); - job.at = now; - job.rev = rev; - let (n, running, ctx) = (job.n.clone(), job.running.clone(), ui.ctx().clone()); - let work = move || { - let missing = if cfg!(target_arch = "wasm32") { 0 } else { paths.iter().filter(|p| !std::path::Path::new(p).exists()).count() }; - n.store(missing, Relaxed); - running.store(false, Relaxed); - ctx.request_repaint(); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(work); - #[cfg(target_arch = "wasm32")] - work(); - ui.data_mut(|d| d.insert_temp(id, job.clone())); - } - job.n.load(Relaxed) -} - -/// Local's folders as they stand this frame, and the folder being browsed among them. -struct LocalNow { - places: LocalPlaces, - current: Option, - /// The places removed from Local, as (name, path), in the order they were hidden. - hidden: Vec<(String, String)>, -} - -/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, -/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse -/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this -/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web). -fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { - if !SidebarSection::Local.available() { - return None; - } - let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); - let mut builtin: Vec<(String, String)> = Vec::new(); - if !home.is_empty() { - for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] { - // joined with the platform's separator, like the paths browsing gives back - let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }; - // (checked off the UI thread: a home folder can be on a network share) - if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) { - builtin.push((crate::i18n::tr(name).to_string(), p)); - } - } - } - let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); - let current = browsing.as_ref().map(|b| b.path.clone()); - // a hidden built-in place keeps its name (Home, not the user's folder name) - let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); - let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); - let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); - if current.is_some() { - app.ui.local_browse_root = places.browse_root.clone(); - } - Some(LocalNow { places, current, hidden }) -} - -/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. -fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { - let (local, current) = (&now.places, now.current.as_deref()); - for (i, (name, path)) in local.places.iter().enumerate() { - let transient = local.browse_root.as_deref() == Some(path.as_str()); - 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() - { - 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})) { - // the picked folder stays in Local (and comes back if it was hidden) - Ok(r) => { - let dir = r["path"].as_str().unwrap_or(&path).to_string(); - let _ = app.run("local.addRoot", json!({"path": dir})); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - } - // the way back for hidden places: one comes back from the row itself; several are listed, so - // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) - 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) - .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() { - let _ = app.run("local.restoreHidden", json!({"path": only})); - } - } else { - egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); - } - } -} - -/// The list behind "Show N hidden locations": each place by name (its path on hover), then -/// Restore All. -fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { - for (name, path) in hidden { - let r = ui.button(name).on_hover_text(path); - register(ui.ctx(), format!("local:restoreHidden:{path}"), r.rect); - if r.clicked() { - let _ = app.run("local.restoreHidden", json!({"path": path})); - ui.close(); - } - } - ui.separator(); - let r = ui.button(crate::i18n::tr("Restore All")); - register(ui.ctx(), "local:restoreHidden:all", r.rect); - if r.clicked() { - let _ = app.run("local.restoreHidden", json!({})); - ui.close(); - } -} - -/// Whether two paths name the same folder, however they are spelled (separators, trailing -/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). -pub(crate) fn same_folder(a: &str, b: &str) -> bool { - a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b) -} - -/// Local's top-level folders and how the folder being browsed sits among them. -#[derive(Debug, Default, PartialEq)] -pub(crate) struct LocalPlaces { - /// (label, path) of each top-level folder, in order. - pub places: Vec<(String, String)>, - /// The top-level folder the browsed folder lies in (the innermost one): its tree opens on - /// the way down to it. None when that way passes through a hidden folder. - pub owner: Option, - /// A folder listed only for this session because the browsed folder is in no saved - /// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it. - pub browse_root: Option, -} - -fn folder_label(path: &str) -> String { - std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) -} - -/// Local's top-level folders, in order: the built-in places, the folders kept with Browse -/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those — -/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else -/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the -/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed -/// once however its path is spelled (the first spelling wins), and hidden ones are left out — -/// they stay reachable through breadcrumbs and Browse Folder…. -pub(crate) fn local_places( - builtin: Vec<(String, String)>, - saved: &[String], - browsing: Option<&str>, - browse_root: Option<&str>, - hidden: &[String], -) -> LocalPlaces { - use lightcraft_catalog::query::{folder_key, folder_within}; - let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p)); - let mut places = builtin; - for path in saved { - if !places.iter().any(|(_, p)| same_folder(p, path)) { - places.push((folder_label(path), path.clone())); - } - } - places.retain(|(_, p)| !is_hidden(p)); - let mut out = LocalPlaces::default(); - if let Some(c) = browsing - && !places.iter().any(|(_, p)| folder_within(c, p)) - { - let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c); - if !is_hidden(root) { - places.push((folder_label(root), root.to_string())); - out.browse_root = Some(root.to_string()); - } - } - if let Some(c) = browsing { - // A tree never opens on the way down through a hidden folder: hiding a kept folder - // beneath Home would otherwise reveal it again inside Home's (possibly huge) tree. - let through_hidden = |p: &str| hidden.iter().any(|h| folder_within(c, h) && folder_within(h, p)); - out.owner = places - .iter() - .enumerate() - .filter(|(_, (_, p))| folder_within(c, p) && !through_hidden(p)) - .max_by_key(|(_, (_, p))| folder_key(p).len()) - .map(|(i, _)| i); - } - out.places = places; - out -} - -/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every -/// 2 s per folder (empty until the first listing). -fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { - fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default() -} - -fn list_subfolders(path: &str) -> Vec<(String, String)> { - let mut v: Vec<(String, String)> = std::fs::read_dir(path) - .map(|rd| { - rd.flatten() - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .filter_map(|e| { - let name = e.file_name().to_string_lossy().to_string(); - (!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string())) - }) - .collect() - }) - .unwrap_or_default(); - v.sort_by_key(|(n, _)| n.to_lowercase()); - v -} - -/// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and -/// the sidebar below them moves) when they land, so the headless driver counts them as pending -/// work and waits for them before acting on widget positions. -pub(crate) fn fs_cached_running(ctx: &egui::Context) -> usize { - fs_running_counter(ctx).load(std::sync::atomic::Ordering::Acquire) -} - -fn fs_running_counter(ctx: &egui::Context) -> std::sync::Arc { - ctx.data_mut(|d| d.get_temp_mut_or_default::>(egui::Id::new("fs-cached-running")).clone()) -} - -/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker -/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a -/// spinning-up drive never blocks a frame. `None` until the first answer arrives. -pub(crate) fn fs_cached(ui: &egui::Ui, kind: &'static str, path: &str, every: f64, f: fn(&str) -> T) -> Option { - struct Entry { - value: Option, - at: Option, - running: bool, - } - type Cell = std::sync::Arc>>; - let id = egui::Id::new(("fs-cached", kind, path.to_string())); - let now = ui.input(|i| i.time); - let cell: Cell = match ui.data(|d| d.get_temp::>(id)) { - Some(c) => c, - None => { - let c: Cell = std::sync::Arc::new(std::sync::Mutex::new(Entry { value: None, at: None, running: false })); - ui.data_mut(|d| d.insert_temp(id, c.clone())); - c - } - }; - let lock = |c: &Cell| c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).value.clone(); - let start = { - let mut e = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let due = !e.running && e.at.is_none_or(|at| now - at >= every || now < at); - if due { - e.running = true; - e.at = Some(now); - } - due - }; - if start { - use std::sync::atomic::Ordering; - let running = fs_running_counter(ui.ctx()); - running.fetch_add(1, Ordering::AcqRel); - let (out, path, repaint, done) = (cell.clone(), path.to_string(), ui.ctx().clone(), running.clone()); - let work = move || { - let v = f(&path); - let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - e.value = Some(v); - e.running = false; - drop(e); - // after the answer is stored: a frame that sees the count drop also sees the answer - done.fetch_sub(1, Ordering::AcqRel); - repaint.request_repaint(); - }; - #[cfg(not(target_arch = "wasm32"))] - if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() { - cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false; - running.fetch_sub(1, Ordering::AcqRel); - } - #[cfg(target_arch = "wasm32")] - work(); - } - lock(&cell) -} - -/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its -/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the -/// way down to it open whenever the browsed folder changes, so it shows highlighted in place. -/// `transient`: a top-level row listed for this session only (it offers Keep in Local). -/// `place`: the top-level folder this tree hangs from. A folder can be listed in more than one -/// tree (Pictures on its own and inside Home): each row opens and closes by itself, and their -/// triangles have ids of their own, else the copy drawn last takes the other's clicks (#1093). -#[allow(clippy::too_many_arguments)] -fn folder_tree( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - place: &str, - name: &str, - path: &str, - indent: f32, - current: Option<&str>, - reveal: bool, - transient: bool, -) { - let open_id = egui::Id::new(("folder-open", place.to_string(), path.to_string())); - let sel = current.is_some_and(|c| same_folder(c, path)); - let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); - let mut open = tree_open(ui, open_id, false); - if reveal && on_the_way { - // opened once per browsed folder: collapsing it again afterwards sticks - let revealed_id = egui::Id::new(("folder-revealed", place.to_string(), path.to_string())); - let target = current.map(str::to_string); - if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { - open = true; - set_tree_open(ui, open_id, true); - ui.data_mut(|d| d.insert_temp(revealed_id, target)); - } - } - // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the - // icon lines up with the rows above - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); - let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); - if !tr.clicked() - && resp.clicked() - && let Err(e) = app.run("library.browse", json!({"path": path})) - { - app.toast(ui.ctx(), e); - } - crate::widgets::context_menu(&resp, |ui| { - // the library actions of the Folders tree, when this folder (or one inside it) holds library photos - let library = app.caches.folder_tree(&app.session.catalog); - let held = library_node_for(&library, path); - if (transient || indent > 0.0) - && ui - .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) - .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) - .clicked() - { - let _ = app.run("local.addRoot", json!({"path": path})); - ui.close(); - } - if indent == 0.0 - && ui - .button(crate::i18n::tr("Remove from Local")) - .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) - .clicked() - { - let _ = app.run("local.hide", json!({"path": path})); - ui.close(); - } - if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("Rename “{name}”", name = name), - hint: "Folder name (renamed on disk; its photos follow)".into(), - value: name.to_string(), - command: "folder.rename".into(), - params: json!({"path": path}), - key: "name".into(), - }); - } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), - } - } - } - if app.services.reveal.is_some() - && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() - && let Some(f) = app.services.reveal.as_mut() - { - let _ = f(path); - } - if let Some(held) = &held { - synchronize_item(app, ui, &held.0, &held.1); - } - }); - if open && indent < INDENT_STEP * 8.0 { - for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); - } - } -} - -/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. -fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { - let open_id = egui::Id::new(("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); - if key.len() < 10 { - tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); - } - if resp.clicked() { - let v = if sel { serde_json::Value::Null } else { json!(key) }; - browse_all_photos(app, !sel); - let _ = app.run("library.filter", json!({"date": v})); - } - open -} - -/// By Date and Keywords count every photo in the library, so choosing a row shows those photos -/// from All Photos, not from whatever album or folder happened to be open, where they could be -/// missing (issue #341). Only when choosing (`on`), not when clearing the row again. -pub(crate) fn browse_all_photos(app: &mut LightcraftApp, on: bool) { - if on && app.session.source != LibrarySource::All { - let _ = app.run("library.source", json!({"kind": "all"})); - } -} - -/// The albums inside each folder (`None`: the top level), in the order they are listed. -type AlbumKids = std::collections::HashMap, Vec>; - -/// `open_to`: folders forced open this frame (the way down to an album that was just made). -fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option, indent: f32, open_to: &[AlbumId]) { - // 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack - if indent > 64.0 * INDENT_STEP { - return; - } - let Some(kids) = all.get(&parent) else { return }; - for a in kids { - if a.folder { - let open_id = egui::Id::new(("folder-open", a.id.0)); - let mut open = tree_open(ui, open_id, true); - if open_to.contains(&a.id) && !open { - 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); - // 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() { - app.ui.dragging_album = Some(a.id.0); - } - if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) { - set_tree_open(ui, open_id, true); - } - // what this frame draws; a click shows from the next one - let draw_open = open; - let mut toggled = false; - if has_children { - let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0)); - toggled = tri.clicked(); - // the triangle sits on the row and takes its clicks: the menu opens from it too - folder_menu(app, &tri, a); - } - // one click, one toggle: the triangle's is not the row's as well - if resp.clicked() && !toggled { - set_tree_open(ui, open_id, !open); - } - folder_menu(app, &resp, a); - if draw_open { - albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to); - } - } else { - let sel = app.session.source == LibrarySource::Album(a.id); - let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; - // cached: a smart album's count scans the catalog - let now = (app.session.clock)(); - let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0); - // the album B adds to is marked "+" - let target = - app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); - let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; - // 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); - if !problems.is_empty() { - crate::widgets::register(ui.ctx(), format!("albumProblem:{}", a.id.0), resp.rect); - } - if resp.drag_started() { - app.ui.dragging_album = Some(a.id.0); - } - album_drag_over(app, ui, &resp, a, None, indent); - if !a.is_smart() { - drop_target(app, ui, &resp, a); - } - if let Some(rules) = &a.smart { - // one tooltip: the rules, then what needs fixing in them - let mut tip = crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog)); - if !problems.is_empty() { - tip.push_str("\n\n"); - tip.push_str(crate::i18n::tr("Some rules need fixing (Edit Smart Album…):")); - for p in &problems { - tip.push('\n'); - tip.push_str(&crate::i18n::problem_line(p)); - } - } - resp = resp.on_hover_text(tip); - } - if resp.clicked() { - let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); - } - folder_menu(app, &resp, a); - } - } -} - -/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of -/// sight is not a drop target). -fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { - crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos())) -} - -/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the -/// pointer there is over the header, not over the row. -fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool { - pointer_over(ui, rect) && !pointer_on_pinned_header(ui) -} - -/// Where the pinned header was drawn last frame, if one was (rows are laid out before it). -fn pinned_rect(ui: &egui::Ui) -> Option { - ui.data(|d| d.get_temp::>(egui::Id::new("left-pinned-rect"))).flatten() -} - -fn pointer_on_pinned_header(ui: &egui::Ui) -> bool { - let pos = ui.input(|i| i.pointer.latest_pos()); - pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p)) -} - -/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. -const SCROLL_EDGE: f32 = 36.0; -/// The fastest the sidebar scrolls for a drag (points per second). -const SCROLL_MAX_SPEED: f32 = 700.0; - -/// How fast the sidebar scrolls (points per second, positive = down) for a dragged item with the -/// pointer at `y`, the visible part being `top..bottom`: none in the middle, faster the nearer to -/// the edge, the top speed at and past it. -pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { - let depth = |dist: f32| ((SCROLL_EDGE - dist) / SCROLL_EDGE).clamp(0.0, 1.0); - SCROLL_MAX_SPEED * (depth(bottom - y) - depth(y - top)) -} - -/// While an album, or photos from the grid, are dragged over the sidebar: scrolls it when the -/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call -/// inside the scroll area. -fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { - if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() { - return; - } - let view = ui.clip_rect(); - let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; - // a section picked up by its pinned header starts inside the top edge: it scrolls once it has - // left the header. Any other drag scrolls there as at any edge, to reach what is above - let from_pinned = egui::Id::new("left-section-drag-from-pinned"); - if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::(from_pinned)) == Some(true) { - if pointer_on_pinned_header(ui) { - return; - } - ui.data_mut(|d| d.insert_temp(from_pinned, false)); - } - let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); - if speed != 0.0 { - // a positive delta moves the content down: the view goes up - ui.scroll_with_delta_animation(vec2(0.0, -speed * ui.input(|i| i.stable_dt).min(0.1)), egui::style::ScrollAnimation::none()); - ui.ctx().request_repaint(); - } -} - -/// Room a colour label's dot takes before a row's count. -const MARK_W: f32 = 12.0; - -/// How long a dragged album must rest on a closed folder before it opens. -const HOVER_OPEN_SECS: f64 = 0.6; - -/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level): -/// a folder other than where it already is, and not itself or something inside it. -fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option) -> bool { - let Some(d) = app.session.catalog.album(dragged) else { return false }; - match target { - None => d.parent.is_some(), - Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged), - } -} - -/// Ends a drag that ran `result`: the folders down to the album open so it shows in its new place. -fn finish_drop(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, result: Result) { - forget_hover(app, ui.ctx()); - match result { - Ok(_) => { - app.ui.reveal_album = Some(dragged.0); - ui.ctx().request_repaint(); - } - Err(e) => app.toast(ui.ctx(), e), - } - app.ui.dragging_album = None; -} - -/// Forgets how long the pointer rested on each folder (a new drag starts counting again). -fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) { - let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); - ctx.data_mut(|d| { - for id in ids { - d.remove_temp::(egui::Id::new(("album-hover", id))); - } - }); -} - -/// Moves the dragged album into a folder (`album.move`, one undo step; `None`: the top level). -fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { - let r = app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})); - finish_drop(app, ui, dragged, r); -} - -/// Places the dragged album in `parent` before the sibling `before` (`None`: last of its kind), by -/// hand (`album.reorder`, one undo step). -fn place_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, parent: Option, before: Option) { - let r = app.run("album.reorder", json!({"id": dragged.0, "parent": parent.map(|p| p.0), "before": before.map(|b| b.0)})); - finish_drop(app, ui, dragged, r); -} - -/// What dropping the dragged album on a row would do. -#[derive(Clone, Copy, Debug, PartialEq)] -enum AlbumDrop { - /// Into the folder. - Into(AlbumId), - /// Beside the row: in `parent`, before `before` (`None`: last of its kind); the line is drawn - /// above the row (`above`) or below it. - Place { parent: Option, before: Option, above: bool }, -} - -/// What dropping `dragged` at `frac` (0 = top edge, 1 = bottom edge) of the row of `target` does, -/// if anything. An album over a folder goes inside it; a folder over a folder goes inside by its -/// middle and beside it by its edges; an album over an album goes before it by its top half and -/// after it by its bottom half; a folder over an album does nothing. Placing where it already -/// is, or inside itself, is nothing too. `expanded`: `target` is a folder shown open. -fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f32, expanded: bool) -> Option { - let cat = &app.session.catalog; - let d = cat.album(dragged)?; - let into = || can_drop_album(app, dragged, Some(target.id)).then_some(AlbumDrop::Into(target.id)); - if d.folder != target.folder { - return if target.folder { into() } else { None }; - } - if target.id == dragged { - return None; - } - let after = frac >= if target.folder { 0.7 } else { 0.5 }; - // the middle of a folder, and the bottom of an open one (its first child is next), mean inside - if target.folder && frac >= 0.3 && (!after || expanded) { - return into(); - } - let parent = target.parent; - if parent.is_some_and(|p| is_within(app, p, dragged)) { - return None; - } - let sibs = cat.album_children(parent); - let at = sibs.iter().position(|a| a.id == target.id)?; - let was = sibs.iter().position(|a| a.id == dragged); - if !after { - (was.map(|w| w + 1) != Some(at)).then_some(AlbumDrop::Place { parent, before: Some(target.id), above: true }) - } else { - let next = sibs.iter().skip(at + 1).find(|a| a.id != dragged).filter(|a| a.folder == target.folder).map(|a| a.id); - (was != Some(at + 1)).then_some(AlbumDrop::Place { parent, before: next, above: false }) - } -} - -/// A row while an album is dragged: a folder outlined under the pointer when the drop would go -/// inside it, a line above or below the row when it would go beside it, and the move or placement -/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on -/// it; returns whether it did. -fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { - let hover_id = egui::Id::new(("album-hover", row.id.0)); - let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; - let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect)); - let Some(pos) = pos else { - if ui.data(|d| d.get_temp::(hover_id)).is_some() { - ui.data_mut(|d| d.remove_temp::(hover_id)); - } - return false; - }; - let expanded = open.as_deref().copied().unwrap_or(false); - let frac = ((pos.y - resp.rect.top()) / resp.rect.height().max(1.0)).clamp(0.0, 1.0); - let drop = album_drop_at(app, dragged, row, frac, expanded); - let mut opened = false; - // only a drop that goes inside opens the folder: on its edges the rows would shift under the pointer - let inside = matches!(drop, Some(AlbumDrop::Into(_))); - if !inside && ui.data(|d| d.get_temp::(hover_id)).is_some() { - ui.data_mut(|d| d.remove_temp::(hover_id)); - } - if inside - && let Some(open) = open - && !*open - { - let now = ui.input(|i| i.time); - let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); - if now - since >= HOVER_OPEN_SECS { - *open = true; - opened = true; - } else { - ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); - } - } - let t = Tokens::get(ui.ctx()); - let released = ui.input(|i| i.pointer.primary_released()); - match drop { - Some(AlbumDrop::Into(folder)) => { - 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 { - drop_album(app, ui, dragged, Some(folder)); - } - } - Some(AlbumDrop::Place { parent, before, above }) => { - let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 }; - let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0); - ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent)); - ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent)); - if released { - place_album(app, ui, dragged, parent, before); - } - } - None => {} - } - opened -} - -/// While an album that sits in a folder is dragged: the "Albums" header takes it back to the top -/// level (outlined, with a hint). The header stays in view however long the tree is, and nothing -/// moves when it appears. -fn top_level_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) { - let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return }; - if !can_drop_album(app, dragged, None) { - return; - } - let t = Tokens::get(ui.ctx()); - register(ui.ctx(), "albumDrop:top", header); - let over = pointer_over(ui, header); - let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; - ui.painter().rect_stroke(header.shrink2(vec2(8.0, 3.0)), 4.0, stroke, egui::StrokeKind::Inside); - // right-aligned, left of the + button - let edge = header.right().min(ui.clip_rect().right()); - ui.painter().text(pos2(edge - 56.0, header.center().y), Align2::RIGHT_CENTER, crate::i18n::tr("Top Level"), t.font(12.5), t.text_dim); - if over && ui.input(|i| i.pointer.primary_released()) { - drop_album(app, ui, dragged, None); - } -} - -/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the -/// button is up (the drop targets act on the release frame, before this runs). -pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(id) = app.ui.dragging_album else { return }; - let (released, down, pos, esc) = - ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); - if released || !down || esc { - app.ui.dragging_album = None; - forget_hover(app, ctx); - return; - } - let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; - let t = Tokens::get(ctx); - ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - egui::Area::new(egui::Id::new("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { - egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0))); - }); - }); -} - -/// An album row while photos are dragged from the grid: highlighted under the pointer; a -/// release there adds them. -fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { - let Some(ids) = app.ui.dragging_photos.clone() else { return }; - // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header - let over = pointer_over_row(ui, resp.rect); - if !over { - return; - } - let released = ui.input(|i| i.pointer.any_released()); - // albums hold library photos (issue #684): Local ones can't be dropped in; the row doesn't - // light up, and a release says why - let photos: Vec = ids.iter().map(|id| lightcraft_catalog::PhotoId(*id)).collect(); - if super::grid::local_only(app, &photos) { - ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); - if released { - app.toast(ui.ctx(), super::grid::albums_hint()); - app.ui.dragging_photos = None; - } - return; - } - 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), - } - app.ui.dragging_photos = None; - } -} - -fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { - crate::widgets::context_menu(resp, |ui| { - if a.folder { - // the same commands as File ▸ New…, aimed at this folder - crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { - for (label, command) in [ - ("Create Album…", "dialog.newAlbum"), - ("Create Smart Album…", "dialog.smartAlbum"), - ("Create Smart Album from Filter…", "dialog.newSmartAlbum"), - ("Create Folder…", "dialog.newFolder"), - ] { - if ui.button(crate::i18n::tr(label)).clicked() { - let _ = app.run(command, json!({"parent": a.id.0})); - ui.close(); - } - } - }); - if app.session.catalog.album_children_are_ordered(Some(a.id)) - && ui.button(crate::i18n::tr("Sort Contents A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() - { - let _ = app.run("album.sort", json!({"parent": a.id.0})); - ui.close(); - } - ui.separator(); - } - if !a.folder && !a.is_smart() { - let local_only = super::grid::local_only(app, &app.session.selected()); - let b = ui - .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) - .on_disabled_hover_text(super::grid::albums_hint()); - if b.clicked() { - match app.run("album.addPhotos", json!({"id": a.id.0})) { - Ok(r) => super::grid::toast_skipped(app, ui.ctx(), &r), - Err(e) => app.toast(ui.ctx(), e), - } - } - // File ▸ Import's ways in; the import review opens with this album chosen - let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| { - let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { - let r = ui.button(label); - crate::widgets::register(ui.ctx(), widget, r.rect); - if r.clicked() { - if let Err(e) = app.run(command, params) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }; - for (label, command, widget) in [ - ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), - ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), - ] { - entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); - } - // like File ▸ Import from Device: one entry per camera or card found - let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| { - let devices = lightcraft_engine::devices::devices(); - if devices.is_empty() { - ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); - } - for (i, d) in devices.into_iter().enumerate() { - let params = json!({"path": d.path, "album": a.id.0}); - entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params); - } - }); - crate::widgets::register(ui.ctx(), "button:albumImportDevice", device.response.rect); - }); - crate::widgets::register(ui.ctx(), "button:albumImport", menu.response.rect); - } - if !a.folder && !a.is_smart() { - let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); - if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() { - let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }})); - } - if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() { - let _ = app.run("album.setTarget", json!({"id": null})); - } - } - if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() { - let _ = app.run("album.clearQuick", json!({})); - } - if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() { - // the same editor as `dialog.smartAlbum` (older smart albums keep their filter fields; - // it works on the rule set, upgraded) - let _ = app.run("dialog.smartAlbum", json!({"id": a.id.0})); - } - if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { - // refused when the view would make the album test itself: say why - if let Err(e) = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})) { - app.toast(ui.ctx(), e); - } - } - if !a.folder { - // export: show the album, select its photos, then the dialog / a preset - let show_all = |app: &mut LightcraftApp| { - let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); - let _ = app.run("library.selectAll", json!({})); - }; - let has_photos = app.session.catalog.album_count(a.id) > 0; - if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() { - show_all(app); - let _ = app.run("dialog.export", json!({})); - } - ui.add_enabled_ui(has_photos, |ui| { - crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| { - for (p, _) in app.session.all_export_presets() { - if ui.button(&p.name).clicked() { - show_all(app); - if let Err(e) = app.run("app.export", json!({"preset": p.name, "background": true})) { - app.toast(ui.ctx(), e); - } - } - } - }); - }); - ui.separator(); - } - // move into another folder (not into itself or one of its own subfolders) - let mut folders: Vec<(u64, String)> = - app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); - folders.sort_by_key(|(_, n)| n.to_lowercase()); - crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| { - if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { - let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); - } - for (fid, name) in &folders { - if ui.add_enabled(a.parent.map(|p| p.0) != Some(*fid), egui::Button::new(name)).clicked() { - let _ = app.run("album.move", json!({"id": a.id.0, "parent": fid})); - } - } - }); - if ui.button(crate::i18n::tr("Rename…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); - } - let delete = ui.button(crate::i18n::tr("Delete")); - register(ui.ctx(), format!("albumDelete:{}", a.id.0), delete.rect); - let delete = - delete.on_hover_text(crate::i18n::tr("Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.")); - if delete.clicked() { - if let Err(e) = app.run("album.delete", json!({"id": a.id.0})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }); -} - -/// Whether `id` is `ancestor` or lies inside it. -fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lightcraft_catalog::AlbumId) -> bool { - let mut cur = Some(id); - let mut guard = 0; - while let Some(c) = cur { - if c == ancestor { - return true; - } - cur = app.session.catalog.album(c).and_then(|x| x.parent); - guard += 1; - if guard > 64 { - break; - } - } - false -} - -// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see -// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local -// folder: its photos and those of the folders inside it fill the grid. The triangle opens a -// level. Only folders holding imported photos are listed; every folder on disk is under Local. - -/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows -/// above it so it is on screen; folding one by hand afterwards sticks until the choice changes. -fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { - let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder); - let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty()); - let seen = egui::Id::new(LIBFOLDER_REVEALED); - let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key); - if ui.data(|d| d.get_temp::>(seen)) == Some(now.clone()) { - return; - } - ui.data_mut(|d| d.insert_temp(seen, now)); - let Some(chosen) = chosen else { return }; - fn open_above(ui: &egui::Ui, nodes: &[FolderNode], chosen: &str) { - for n in nodes { - if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) { - let key = lightcraft_catalog::query::folder_key(&n.path); - ui.data_mut(|d| d.insert_temp(egui::Id::new(("libfolder-open", key)), true)); - open_above(ui, &n.children, chosen); - } - } - } - open_above(ui, tree, &chosen); -} - -/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that -/// folder. -pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> { - // only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths - // too, so more than one may), and the tree is at most `MAX_DEPTH` deep - tree.iter() - .filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path)) - .find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) }) -} - -fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) { - for n in nodes { - let key = lightcraft_catalog::query::folder_key(&n.path); - let open_id = egui::Id::new(("libfolder-open", key.clone())); - // a disk starts open, and so does a folder that holds nothing itself and leads to one - // folder (`Users` → `me`): the first row where the library branches is what you look for - let leads_on = n.own == 0 && n.children.len() == 1; - let mut open = tree_open(ui, open_id, n.volume || leads_on); - // a row whose path would cover other disks' photos too only opens and closes - let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; - let id = format!("libfolder:{}", n.path); - if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) { - folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); - } - if open && !n.children.is_empty() { - folder_rows(app, ui, &n.children, indent + INDENT_STEP); - } - } -} - -/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's. -#[allow(clippy::too_many_arguments)] -fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) { - let sel = n.selectable - && app.session.source == LibrarySource::LibraryFolder - && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); - // the label is said with the name, as it is shown beside it - let spoken = match n.label { - Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), - 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); - folder_drop_target(app, ui, &resp, n, open_id, open); - let mut toggled = false; - if !n.children.is_empty() { - let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); - toggled = tr.clicked(); - // the triangle sits on the row and takes its clicks: the menu opens from it too - row_menu(app, &tr, n); - } - let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; - let resp = resp.on_hover_text(tip); - if resp.clicked() && !toggled { - if n.selectable { - // a source like an album or a Local folder: it replaces what the grid showed - let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); - } else { - *open = !*open; - set_tree_open(ui, open_id, *open); - } - } - row_menu(app, &resp, n); -} - -/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it -/// can be reached. -const SPRING_OPEN_SECONDS: f64 = 0.7; - -/// A Folders row while photos are dragged from the grid: a release moves their files there on -/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where -/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, -/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, -/// not in the library) and a release says why; photos already there say so. -fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { - let Some(ids) = app.ui.dragging_photos.clone() else { return }; - // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header - if !pointer_over_row(ui, resp.rect) { - return; - } - spring_open(ui, n, open_id, open); - let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); - let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); - let refused = if !n.selectable { - Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) - } else if photos.iter().any(|p| p.local) { - Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) - } else { - None - }; - let here = |p: &lightcraft_catalog::Photo| match &p.source { - lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), - _ => false, - }; - let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); - let count = photos.len(); - let released = ui.input(|i| i.pointer.any_released()); - let hint = match (&refused, all_here) { - (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), - (None, true) => crate::i18n::tr_format!("Already in “{}”", name), - (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), - }; - super::grid::drag_hint(ui.ctx(), hint); - if let Some(why) = refused { - ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); - if released { - app.toast(ui.ctx(), why); - app.ui.dragging_photos = None; - } - return; - } - if all_here { - if released { - app.ui.dragging_photos = None; - } - return; - } - 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 { - app.ui.dragging_photos = None; - // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person - // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once - if app.ui.settings.ask_before_move { - let library = app.session.library_generation(); - app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); - } else { - move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); - } - } -} - -/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders -/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after -/// leaving it (or after another drag). -fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { - if *open || n.children.is_empty() { - return; - } - let key = egui::Id::new("folder-spring-open"); - let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); - // held without a break: over this row last frame too - let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { - Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, - _ => now, - }; - ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); - if now - since >= SPRING_OPEN_SECONDS { - *open = true; - set_tree_open(ui, open_id, true); - ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); - } else { - // keep the clock running while the pointer rests - ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); - } -} - -/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). -/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches -/// photos no longer on screen. How many moved, or why none did. -pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result { - let mut params = json!({"folder": path}); - if let Some(ids) = ids { - params["ids"] = json!(ids); - } - let r = app.run("photo.moveToFolder", params)?; - let visible: std::collections::HashSet = app.session.visible_cloned().into_iter().collect(); - let sel = &mut app.session.selection; - sel.ids.retain(|id| visible.contains(id)); - if sel.active.is_some_and(|a| !visible.contains(&a)) { - sel.active = sel.ids.first().copied(); - } - Ok(r["moved"].as_u64().unwrap_or(0)) -} - -/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them -/// back, or that they were there already. -pub(crate) fn moved_text(n: u64, name: &str) -> String { - if n == 0 { - crate::i18n::tr_format!("Already in “{}”", name) - } else { - crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) - } -} - -/// [`move_photos`], saying how it went in a toast. -fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { - match move_photos(app, ids, path) { - Ok(n) => app.toast(ctx, moved_text(n, name)), - Err(e) => app.toast(ctx, e), - } -} - -/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the -/// startup disk. -fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { - if !n.volume { - folder_menu_for_library(app, resp, n); - } else if n.path != "/" { - crate::widgets::context_menu(resp, |ui| { - folder_label_menu(app, ui, n); - locate_missing_item(app, ui, n); - synchronize_item(app, ui, n, &n.name); - ui.separator(); - if ui - .button(crate::i18n::tr("Remove Disk from Library…")) - .on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched")) - .clicked() - { - app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: n.path.clone(), name: n.name.clone(), count: n.count, disk: true }); - ui.close(); - } - }); - } -} - -/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing -/// background finder relink only this folder's photos (including the folders inside it). -fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { - let r = ui.button(crate::i18n::tr("Locate Missing Files…")); - register(ui.ctx(), "folderLocateMissing", r.rect); - if r.clicked() { - if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } -} - -/// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows: -/// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder -/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the -/// cached Folders tree only; the disk is not touched. -fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> { - use lightcraft_catalog::query::folder_within; - fn exact<'a>(tree: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> { - tree.iter().find_map(|n| if same_folder(&n.path, path) { Some(n) } else { exact(&n.children, path) }) - } - fn any_inside(tree: &[FolderNode], path: &str) -> bool { - tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path)) - } - if let Some(n) = exact(tree, path).filter(|n| n.count > 0) { - return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path))); - } - if !any_inside(tree, path) { - return None; - } - let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - let node = FolderNode { - name: name.clone(), - path: path.to_string(), - count: 0, - own: 0, - volume: false, - selectable: false, - label: None, - children: Vec::new(), - }; - Some((node, name)) -} - -/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which -/// scans the folder in the background. -fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { - let r = ui - .button(crate::i18n::tr("Synchronize Folder…")) - .on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps")); - crate::widgets::register(ui.ctx(), "folderSynchronize", r.rect); - if r.clicked() { - // a disk, or a folder holding disks, was chosen as such - if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) { - app.toast(ui.ctx(), e); - } - ui.close(); - } -} - -/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, -/// its colours `folderLabel:`). -fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { - let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| { - if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { - if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }); - register(ui.ctx(), "folderLabelMenu", menu.response.rect); -} - -/// The context menu of a folder row: the folder's disk actions (the same as Local's, photos -/// follow) and taking its photos out of the library. -fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { - let path = n.path.as_str(); - let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - // what dialogs call it: the last two names, so same-named folders are told apart - let label = lightcraft_catalog::folders::folder_label(path); - crate::widgets::context_menu(resp, |ui| { - if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("Rename “{name}”", name = label), - hint: "Folder name (renamed on disk; its photos follow)".into(), - value: name.clone(), - command: "folder.rename".into(), - params: json!({"path": path}), - key: "name".into(), - }); - ui.close(); - } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), - } - } - ui.close(); - } - if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), - hint: "Folder name (created on disk)".into(), - value: String::new(), - command: "folder.create".into(), - params: json!({"parent": path}), - key: "name".into(), - }); - ui.close(); - } - let selected = app.session.selection.ids.len(); - if selected > 0 - && ui - .button(crate::i18n::tr("Move Selected Photos Here")) - .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) - .clicked() - { - move_photos_here(app, ui.ctx(), None, path, &name); - ui.close(); - } - if app.services.reveal.is_some() - && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() - && let Some(f) = app.services.reveal.as_mut() - { - let _ = f(path); - ui.close(); - } - folder_label_menu(app, ui, n); - locate_missing_item(app, ui, n); - synchronize_item(app, ui, n, &label); - ui.separator(); - if ui - .button(crate::i18n::tr("Remove from Library…")) - .on_hover_text(crate::i18n::tr("Moves the photos imported from this folder to Recently Deleted; no file is touched")) - .clicked() - { - app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: path.to_string(), name: label.clone(), count: n.count, disk: false }); - ui.close(); - } - }); -} - -/// "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 -/// context menu renames, merges or deletes the keyword across the library. -fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { - for n in nodes { - // leaves (most keywords) have no open state to look up - let open_id = (!n.children.is_empty()).then(|| egui::Id::new(("kw-open", n.path.to_lowercase()))); - let mut open = open_id.is_some_and(|k| tree_open(ui, k, false)); - let id = format!("keyword:{}", n.path); - if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) { - keyword_row(app, ui, n, &id, indent, open_id, &mut open); - } - if open { - keyword_rows(app, ui, &n.children, indent + INDENT_STEP); - } - } -} - -/// 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); - if let Some(k) = open_id { - tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); - } - let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); - if resp.clicked() { - let v = if sel { serde_json::Value::Null } else { json!(n.path) }; - browse_all_photos(app, !sel); - let _ = app.run("library.filter", json!({"keyword": v})); - } - crate::widgets::context_menu(&resp, |ui| { - let has_sel = app.session.active().is_some(); - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); - } - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); - } - if ui.button(crate::i18n::tr("Merge into…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); - } - if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { - let _ = app.run("keyword.delete", json!({"keyword": n.path})); - } - }); -} - -#[cfg(test)] -mod tests { - use super::local_places; - use crate::widgets::elide_head; - - /// Width = characters, so a limit of 12 is "12 characters". - fn fit(text: &str, max: usize) -> String { - elide_head(text, max as f32, |s| s.chars().count() as f32) - } - - #[test] - fn a_label_that_fits_is_left_alone() { - assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); - } - - #[test] - fn a_long_path_loses_its_leading_folders_not_its_end() { - assert_eq!(fit("/Users/me/Pictures/Lightroom", 20), "…/Pictures/Lightroom"); - assert_eq!(fit("/Users/me/Pictures/Lightroom", 12), "…/Lightroom"); - } - - #[test] - fn a_single_name_too_long_is_cut_at_the_end() { - assert_eq!(fit("2024-summer-holiday", 8), "2024-su…"); - assert_eq!(fit("2024-summer-holiday", 1), "…"); - assert_eq!(fit("2024-summer-holiday", 0), "…"); - } - - #[test] - fn multibyte_names_are_cut_on_character_boundaries() { - assert_eq!(fit("/写真/夏休み旅行の記録", 6), "夏休み旅行…"); - } - - fn names(v: &[(String, String)]) -> Vec<&str> { - v.iter().map(|(n, _)| n.as_str()).collect() - } - - /// A built-in location browsed (or picked) under another spelling of its path is the same - /// row, not a second root — for every built-in, not just named ones. - #[test] - fn equivalent_paths_are_one_local_location() { - for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] { - let builtin: Vec<(String, String)> = - ["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect(); - for (_, p) in builtin.clone() { - // the browsed spelling: other separators, a trailing one, a different drive-letter case - let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" }); - for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] { - let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]); - assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}"); - assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted"); - } - // hiding under one spelling hides the other - let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped)); - assert_eq!(l.places.len(), 2, "hidden {flipped}"); - } - } - // a different folder is still added - let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]); - assert_eq!(names(&l.places), ["Pictures", "Pictures2"]); - } - - /// A folder inside a kept root is shown inside that root's tree: the root stays (its other - /// subfolders stay reachable) and no second root appears; other kept roots stay too. - #[test] - fn browsing_below_a_kept_root_keeps_the_root() { - let photos = "/data/Photos".to_string(); - let other = "/data/Scans".to_string(); - let saved = [photos.clone(), other.clone()]; - let builtin = || vec![("Home".to_string(), "/home/example".to_string())]; - for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] { - let l = local_places(builtin(), &saved, Some(browsing), None, &[]); - let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect(); - if browsing.starts_with("/data") { - assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}"); - assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}"); - assert_eq!(l.browse_root, None); - } else { - assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row"); - } - } - // the innermost containing root is the one that opens - let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]); - assert_eq!(l.owner, Some(1)); - } - - /// A browsed folder outside every kept root gets a session row, which stays while browsing - /// below it and gives way when browsing moves elsewhere. - #[test] - fn session_root_stays_while_browsing_below_it() { - let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]); - assert_eq!(l.browse_root.as_deref(), Some("/t/base")); - let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]); - assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}"); - let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]); - assert_eq!(l.browse_root.as_deref(), Some("/elsewhere")); - // hidden: no row at all - let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]); - assert!(l.places.is_empty() && l.browse_root.is_none()); - } - - /// A hidden folder inside a listed one (a kept folder beneath Home) is not revealed in that - /// one's tree while it is browsed, nor are the folders below it; hiding Home itself still - /// lets Pictures open down to a folder browsed inside it. - #[test] - fn hidden_folder_is_not_revealed_in_an_outer_tree() { - let builtin = || vec![("Pictures".to_string(), "/home/example/Pictures".to_string()), ("Home".to_string(), "/home/example".to_string())]; - let kept = ["/home/example/AppData/Temp/lc".to_string()]; - let l = local_places(builtin(), &kept, Some("/home/example/AppData/Temp/lc"), None, &[]); - assert_eq!(l.owner, Some(2), "shown as its own kept row"); - for browsing in ["/home/example/AppData/Temp/lc", "/home/example/AppData/Temp/lc/Day 1"] { - let l = local_places(builtin(), &kept, Some(browsing), None, &["/home/example/AppData/Temp/lc/".into()]); - assert_eq!(names(&l.places), ["Pictures", "Home"], "{browsing}"); - assert_eq!((l.owner, l.browse_root.as_deref()), (None, None), "Home does not open down to {browsing}"); - } - let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]); - assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); - } - - #[test] - fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { - use crate::Services; - use crate::headless::HeadlessView; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); - let mut view = HeadlessView::new(); - let path = "/lightcraft-test/Pictures"; - let mut widgets = vec![]; - 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::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); - }); - widgets = crate::widgets::take_registry(&view.ctx); - } - let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; - let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}"))); - assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does"); - let triangle = rect(&format!("folderToggle:{path}")); - // the icon is 16 points wide and ends 8 points before the name - assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}"); - } -} diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs new file mode 100644 index 000000000..e0204c826 --- /dev/null +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -0,0 +1,415 @@ +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::*; + +/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, +/// whose folders open down to it. +pub(super) fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option) { + let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); + let cat = &app.session.catalog; + let mut open_to = Vec::new(); + let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); + while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { + open_to.push(p); + cur = cat.album(p).and_then(|a| a.parent); + } + albums_tree(app, ui, &kids, None, 0.0, &open_to); +} + +/// The albums inside each folder (`None`: the top level), in the order they are listed. +type AlbumKids = std::collections::HashMap, Vec>; + +/// `open_to`: folders forced open this frame (the way down to an album that was just made). +fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option, indent: f32, open_to: &[AlbumId]) { + // 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack + if indent > 64.0 * INDENT_STEP { + return; + } + let Some(kids) = all.get(&parent) else { return }; + for a in kids { + if a.folder { + let open_id = egui::Id::unique(("folder-open", a.id.0)); + let mut open = tree_open(ui, open_id, true); + if open_to.contains(&a.id) && !open { + 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); + // 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() { + app.ui.dragging_album = Some(a.id.0); + } + if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) { + set_tree_open(ui, open_id, true); + } + // what this frame draws; a click shows from the next one + let draw_open = open; + let mut toggled = false; + if has_children { + let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0)); + toggled = tri.clicked(); + // the triangle sits on the row and takes its clicks: the menu opens from it too + folder_menu(app, &tri, a); + } + // one click, one toggle: the triangle's is not the row's as well + if resp.clicked() && !toggled { + set_tree_open(ui, open_id, !open); + } + folder_menu(app, &resp, a); + if draw_open { + albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to); + } + } else { + let sel = app.session.source == LibrarySource::Album(a.id); + let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; + // cached: a smart album's count scans the catalog + let now = (app.session.clock)(); + let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0); + // the album B adds to is marked "+" + let target = + app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); + let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; + // 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); + if !problems.is_empty() { + crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect); + } + if resp.drag_started() { + app.ui.dragging_album = Some(a.id.0); + } + album_drag_over(app, ui, &resp, a, None, indent); + if !a.is_smart() { + drop_target(app, ui, &resp, a); + } + if let Some(rules) = &a.smart { + // one tooltip: the rules, then what needs fixing in them + let mut tip = crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog)); + if !problems.is_empty() { + tip.push_str("\n\n"); + tip.push_str(crate::i18n::tr("Some rules need fixing (Edit Smart Album…):")); + for p in &problems { + tip.push('\n'); + tip.push_str(&crate::i18n::problem_line(p)); + } + } + resp = resp.on_hover_text(tip); + } + if resp.clicked() { + let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); + } + folder_menu(app, &resp, a); + } + } +} + +/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of +/// sight is not a drop target). +fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { + crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos())) +} + +/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the +/// pointer there is over the header, not over the row. +pub(super) fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool { + pointer_over(ui, rect) && !pointer_on_pinned_header(ui) +} + +/// Where the pinned header was drawn last frame, if one was (rows are laid out before it). +fn pinned_rect(ui: &egui::Ui) -> Option { + ui.data(|d| d.get_temp::>(egui::Id::unique("left-pinned-rect"))).flatten() +} + +fn pointer_on_pinned_header(ui: &egui::Ui) -> bool { + let pos = ui.input(|i| i.pointer.latest_pos()); + pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p)) +} + +/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. +const SCROLL_EDGE: f32 = 36.0; + +/// The fastest the sidebar scrolls for a drag (points per second). +const SCROLL_MAX_SPEED: f32 = 700.0; + +/// How fast the sidebar scrolls (points per second, positive = down) for a dragged item with the +/// pointer at `y`, the visible part being `top..bottom`: none in the middle, faster the nearer to +/// the edge, the top speed at and past it. +pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { + let depth = |dist: f32| ((SCROLL_EDGE - dist) / SCROLL_EDGE).clamp(0.0, 1.0); + SCROLL_MAX_SPEED * (depth(bottom - y) - depth(y - top)) +} + +/// While an album, or photos from the grid, are dragged over the sidebar: scrolls it when the +/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call +/// inside the scroll area. +pub(super) fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { + if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() { + return; + } + let view = ui.clip_rect(); + let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; + // a section picked up by its pinned header starts inside the top edge: it scrolls once it has + // left the header. Any other drag scrolls there as at any edge, to reach what is above + let from_pinned = egui::Id::unique("left-section-drag-from-pinned"); + if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::(from_pinned)) == Some(true) { + if pointer_on_pinned_header(ui) { + return; + } + ui.data_mut(|d| d.insert_temp(from_pinned, false)); + } + let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); + if speed != 0.0 { + // a positive delta moves the content down: the view goes up + ui.scroll_with_delta_animation(vec2(0.0, -speed * ui.input(|i| i.stable_dt).min(0.1)), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + } +} + +/// Room a colour label's dot takes before a row's count. +pub(super) const MARK_W: f32 = 12.0; + +/// How long a dragged album must rest on a closed folder before it opens. +const HOVER_OPEN_SECS: f64 = 0.6; + +/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level): +/// a folder other than where it already is, and not itself or something inside it. +fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option) -> bool { + let Some(d) = app.session.catalog.album(dragged) else { return false }; + match target { + None => d.parent.is_some(), + Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged), + } +} + +/// Ends a drag that ran `result`: the folders down to the album open so it shows in its new place. +fn finish_drop(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, result: Result) { + forget_hover(app, ui.ctx()); + match result { + Ok(_) => { + app.ui.reveal_album = Some(dragged.0); + ui.ctx().request_repaint(); + } + Err(e) => app.toast(ui.ctx(), e), + } + app.ui.dragging_album = None; +} + +/// Forgets how long the pointer rested on each folder (a new drag starts counting again). +fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) { + let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); + ctx.data_mut(|d| { + for id in ids { + d.remove_temp::(egui::Id::unique(("album-hover", id))); + } + }); +} + +/// Moves the dragged album into a folder (`album.move`, one undo step; `None`: the top level). +fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { + let r = app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})); + finish_drop(app, ui, dragged, r); +} + +/// Places the dragged album in `parent` before the sibling `before` (`None`: last of its kind), by +/// hand (`album.reorder`, one undo step). +fn place_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, parent: Option, before: Option) { + let r = app.run("album.reorder", json!({"id": dragged.0, "parent": parent.map(|p| p.0), "before": before.map(|b| b.0)})); + finish_drop(app, ui, dragged, r); +} + +/// What dropping the dragged album on a row would do. +#[derive(Clone, Copy, Debug, PartialEq)] +enum AlbumDrop { + /// Into the folder. + Into(AlbumId), + /// Beside the row: in `parent`, before `before` (`None`: last of its kind); the line is drawn + /// above the row (`above`) or below it. + Place { parent: Option, before: Option, above: bool }, +} + +/// What dropping `dragged` at `frac` (0 = top edge, 1 = bottom edge) of the row of `target` does, +/// if anything. An album over a folder goes inside it; a folder over a folder goes inside by its +/// middle and beside it by its edges; an album over an album goes before it by its top half and +/// after it by its bottom half; a folder over an album does nothing. Placing where it already +/// is, or inside itself, is nothing too. `expanded`: `target` is a folder shown open. +fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f32, expanded: bool) -> Option { + let cat = &app.session.catalog; + let d = cat.album(dragged)?; + let into = || can_drop_album(app, dragged, Some(target.id)).then_some(AlbumDrop::Into(target.id)); + if d.folder != target.folder { + return if target.folder { into() } else { None }; + } + if target.id == dragged { + return None; + } + let after = frac >= if target.folder { 0.7 } else { 0.5 }; + // the middle of a folder, and the bottom of an open one (its first child is next), mean inside + if target.folder && frac >= 0.3 && (!after || expanded) { + return into(); + } + let parent = target.parent; + if parent.is_some_and(|p| is_within(app, p, dragged)) { + return None; + } + let sibs = cat.album_children(parent); + let at = sibs.iter().position(|a| a.id == target.id)?; + let was = sibs.iter().position(|a| a.id == dragged); + if !after { + (was.map(|w| w + 1) != Some(at)).then_some(AlbumDrop::Place { parent, before: Some(target.id), above: true }) + } else { + let next = sibs.iter().skip(at + 1).find(|a| a.id != dragged).filter(|a| a.folder == target.folder).map(|a| a.id); + (was != Some(at + 1)).then_some(AlbumDrop::Place { parent, before: next, above: false }) + } +} + +/// A row while an album is dragged: a folder outlined under the pointer when the drop would go +/// inside it, a line above or below the row when it would go beside it, and the move or placement +/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on +/// it; returns whether it did. +fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { + let hover_id = egui::Id::unique(("album-hover", row.id.0)); + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; + let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect)); + let Some(pos) = pos else { + if ui.data(|d| d.get_temp::(hover_id)).is_some() { + ui.data_mut(|d| d.remove_temp::(hover_id)); + } + return false; + }; + let expanded = open.as_deref().copied().unwrap_or(false); + let frac = ((pos.y - resp.rect.top()) / resp.rect.height().max(1.0)).clamp(0.0, 1.0); + let drop = album_drop_at(app, dragged, row, frac, expanded); + let mut opened = false; + // only a drop that goes inside opens the folder: on its edges the rows would shift under the pointer + let inside = matches!(drop, Some(AlbumDrop::Into(_))); + if !inside && ui.data(|d| d.get_temp::(hover_id)).is_some() { + ui.data_mut(|d| d.remove_temp::(hover_id)); + } + if inside + && let Some(open) = open + && !*open + { + let now = ui.input(|i| i.time); + let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); + if now - since >= HOVER_OPEN_SECS { + *open = true; + opened = true; + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + } + let t = Tokens::get(ui.ctx()); + let released = ui.input(|i| i.pointer.primary_released()); + match drop { + Some(AlbumDrop::Into(folder)) => { + 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 { + drop_album(app, ui, dragged, Some(folder)); + } + } + Some(AlbumDrop::Place { parent, before, above }) => { + let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 }; + let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0); + ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent)); + ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent)); + if released { + place_album(app, ui, dragged, parent, before); + } + } + None => {} + } + opened +} + +/// While an album that sits in a folder is dragged: the "Albums" header takes it back to the top +/// level (outlined, with a hint). The header stays in view however long the tree is, and nothing +/// moves when it appears. +pub(super) fn top_level_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) { + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return }; + if !can_drop_album(app, dragged, None) { + return; + } + let t = Tokens::get(ui.ctx()); + register(ui, "albumDrop:top", header); + let over = pointer_over(ui, header); + let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; + ui.painter().rect_stroke(header.shrink2(vec2(8.0, 3.0)), 4.0, stroke, egui::StrokeKind::Inside); + // right-aligned, left of the + button + let edge = header.right().min(ui.clip_rect().right()); + ui.painter().text(pos2(edge - 56.0, header.center().y), Align2::RIGHT_CENTER, crate::i18n::tr("Top Level"), t.font(12.5), t.text_dim); + if over && ui.input(|i| i.pointer.primary_released()) { + drop_album(app, ui, dragged, None); + } +} + +/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the +/// button is up (the drop targets act on the release frame, before this runs). +pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(id) = app.ui.dragging_album else { return }; + let (released, down, pos, esc) = + ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if released || !down || esc { + app.ui.dragging_album = None; + forget_hover(app, ctx); + return; + } + let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::unique("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }, + ); +} + +/// An album row while photos are dragged from the grid: highlighted under the pointer; a +/// release there adds them. +fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + let over = pointer_over_row(ui, resp.rect); + if !over { + return; + } + let released = ui.input(|i| i.pointer.any_released()); + // albums hold library photos (issue #684): Local ones can't be dropped in; the row doesn't + // light up, and a release says why + let photos: Vec = ids.iter().map(|id| lightcraft_catalog::PhotoId(*id)).collect(); + if crate::panels::grid::local_only(app, &photos) { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), crate::panels::grid::albums_hint()); + app.ui.dragging_photos = None; + } + return; + } + 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), + } + app.ui.dragging_photos = None; + } +} diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs new file mode 100644 index 000000000..49d8cf898 --- /dev/null +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -0,0 +1,46 @@ +use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; +use super::*; + +/// 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) { + let groups = app.caches.date_groups(&app.session.catalog); + for g in groups.iter() { + if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { + for (m, n) in &g.months { + let label = crate::i18n::date_group_label(m, true); + if date_row(app, ui, m, &label, *n, INDENT_STEP) { + for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { + let label = crate::i18n::date_group_label(d, true); + date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP); + } + } + } + } + } +} + +/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. +fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { + 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); + if key.len() < 10 { + tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); + } + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(key) }; + browse_all_photos(app, !sel); + let _ = app.run("library.filter", json!({"date": v})); + } + open +} + +/// By Date and Keywords count every photo in the library, so choosing a row shows those photos +/// from All Photos, not from whatever album or folder happened to be open, where they could be +/// missing (issue #341). Only when choosing (`on`), not when clearing the row again. +pub(crate) fn browse_all_photos(app: &mut LightcraftApp, on: bool) { + if on && app.session.source != LibrarySource::All { + let _ = app.run("library.source", json!({"kind": "all"})); + } +} diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs new file mode 100644 index 000000000..b9aa33037 --- /dev/null +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -0,0 +1,661 @@ +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::*; + +/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its +/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the +/// way down to it open whenever the browsed folder changes, so it shows highlighted in place. +/// `transient`: a top-level row listed for this session only (it offers Keep in Local). +/// `place`: the top-level folder this tree hangs from. A folder can be listed in more than one +/// tree (Pictures on its own and inside Home): each row opens and closes by itself, and their +/// triangles have ids of their own, else the copy drawn last takes the other's clicks (#1093). +#[allow(clippy::too_many_arguments)] +pub(super) fn folder_tree( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + place: &str, + name: &str, + path: &str, + indent: f32, + current: Option<&str>, + reveal: bool, + transient: bool, +) { + let open_id = egui::Id::unique(("folder-open", place.to_string(), path.to_string())); + let sel = current.is_some_and(|c| same_folder(c, path)); + let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); + let mut open = tree_open(ui, open_id, false); + if reveal && on_the_way { + // opened once per browsed folder: collapsing it again afterwards sticks + let revealed_id = egui::Id::unique(("folder-revealed", place.to_string(), path.to_string())); + let target = current.map(str::to_string); + if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { + open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.insert_temp(revealed_id, target)); + } + } + // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the + // icon lines up with the rows above + let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); + if !tr.clicked() + && resp.clicked() + && let Err(e) = app.run("library.browse", json!({"path": path})) + { + app.toast(ui.ctx(), e); + } + crate::widgets::context_menu(&resp, |ui| { + // the library actions of the Folders tree, when this folder (or one inside it) holds library photos + let library = app.caches.folder_tree(&app.session.catalog); + let held = library_node_for(&library, path); + if (transient || indent > 0.0) + && ui + .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) + .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) + .clicked() + { + let _ = app.run("local.addRoot", json!({"path": path})); + ui.close(); + } + if indent == 0.0 + && ui + .button(crate::i18n::tr("Remove from Local")) + .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) + .clicked() + { + let _ = app.run("local.hide", json!({"path": path})); + ui.close(); + } + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("Rename “{name}”", name = name), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.to_string(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("folder.move", json!({"path": path, "into": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), + Err(e) => app.toast(ui.ctx(), e), + } + } + } + if app.services.reveal.is_some() + && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() + && let Some(f) = app.services.reveal.as_mut() + { + let _ = f(path); + } + if let Some(held) = &held { + synchronize_item(app, ui, &held.0, &held.1); + } + }); + if open && indent < INDENT_STEP * 8.0 { + for (n, p) in subfolders(ui, path) { + folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); + } + } +} + +pub(super) fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { + crate::widgets::context_menu(resp, |ui| { + if a.folder { + // the same commands as File ▸ New…, aimed at this folder + crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { + for (label, command) in [ + ("Create Album…", "dialog.newAlbum"), + ("Create Smart Album…", "dialog.smartAlbum"), + ("Create Smart Album from Filter…", "dialog.newSmartAlbum"), + ("Create Folder…", "dialog.newFolder"), + ] { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run(command, json!({"parent": a.id.0})); + ui.close(); + } + } + }); + if app.session.catalog.album_children_are_ordered(Some(a.id)) + && ui.button(crate::i18n::tr("Sort Contents A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() + { + let _ = app.run("album.sort", json!({"parent": a.id.0})); + ui.close(); + } + ui.separator(); + } + if !a.folder && !a.is_smart() { + let local_only = crate::panels::grid::local_only(app, &app.session.selected()); + let b = ui + .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) + .on_disabled_hover_text(crate::panels::grid::albums_hint()); + if b.clicked() { + match app.run("album.addPhotos", json!({"id": a.id.0})) { + Ok(r) => crate::panels::grid::toast_skipped(app, ui.ctx(), &r), + Err(e) => app.toast(ui.ctx(), e), + } + } + // File ▸ Import's ways in; the import review opens with this album chosen + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| { + let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { + let r = ui.button(label); + crate::widgets::register(ui, widget, r.rect); + if r.clicked() { + if let Err(e) = app.run(command, params) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }; + for (label, command, widget) in [ + ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), + ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), + ] { + entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); + } + // like File ▸ Import from Device: one entry per camera or card found + let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| { + let devices = lightcraft_engine::devices::devices(); + if devices.is_empty() { + ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); + } + for (i, d) in devices.into_iter().enumerate() { + let params = json!({"path": d.path, "album": a.id.0}); + entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params); + } + }); + crate::widgets::register(ui, "button:albumImportDevice", device.response.rect); + }); + crate::widgets::register(ui, "button:albumImport", menu.response.rect); + } + if !a.folder && !a.is_smart() { + let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); + if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() { + let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }})); + } + if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() { + let _ = app.run("album.setTarget", json!({"id": null})); + } + } + if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() { + let _ = app.run("album.clearQuick", json!({})); + } + if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() { + // the same editor as `dialog.smartAlbum` (older smart albums keep their filter fields; + // it works on the rule set, upgraded) + let _ = app.run("dialog.smartAlbum", json!({"id": a.id.0})); + } + if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { + // refused when the view would make the album test itself: say why + if let Err(e) = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})) { + app.toast(ui.ctx(), e); + } + } + if !a.folder { + // export: show the album, select its photos, then the dialog / a preset + let show_all = |app: &mut LightcraftApp| { + let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); + let _ = app.run("library.selectAll", json!({})); + }; + let has_photos = app.session.catalog.album_count(a.id) > 0; + if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() { + show_all(app); + let _ = app.run("dialog.export", json!({})); + } + ui.add_enabled_ui(has_photos, |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| { + for (p, _) in app.session.all_export_presets() { + if ui.button(&p.name).clicked() { + show_all(app); + if let Err(e) = app.run("app.export", json!({"preset": p.name, "background": true})) { + app.toast(ui.ctx(), e); + } + } + } + }); + }); + ui.separator(); + } + // move into another folder (not into itself or one of its own subfolders) + let mut folders: Vec<(u64, String)> = + app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); + folders.sort_by_key(|(_, n)| n.to_lowercase()); + crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| { + if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { + let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); + } + for (fid, name) in &folders { + if ui.add_enabled(a.parent.map(|p| p.0) != Some(*fid), egui::Button::new(name)).clicked() { + let _ = app.run("album.move", json!({"id": a.id.0, "parent": fid})); + } + } + }); + if ui.button(crate::i18n::tr("Rename…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); + } + let delete = ui.button(crate::i18n::tr("Delete")); + register(ui, format!("albumDelete:{}", a.id.0), delete.rect); + let delete = + delete.on_hover_text(crate::i18n::tr("Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.")); + if delete.clicked() { + if let Err(e) = app.run("album.delete", json!({"id": a.id.0})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); +} + +/// Whether `id` is `ancestor` or lies inside it. +pub(super) fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lightcraft_catalog::AlbumId) -> bool { + let mut cur = Some(id); + let mut guard = 0; + while let Some(c) = cur { + if c == ancestor { + return true; + } + cur = app.session.catalog.album(c).and_then(|x| x.parent); + guard += 1; + if guard > 64 { + break; + } + } + false +} + +// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see +// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local +// folder: its photos and those of the folders inside it fill the grid. The triangle opens a +// level. Only folders holding imported photos are listed; every folder on disk is under Local. + +/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows +/// above it so it is on screen; folding one by hand afterwards sticks until the choice changes. +pub(super) fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { + let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder); + let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty()); + let seen = egui::Id::unique(LIBFOLDER_REVEALED); + let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key); + if ui.data(|d| d.get_temp::>(seen)) == Some(now.clone()) { + return; + } + ui.data_mut(|d| d.insert_temp(seen, now)); + let Some(chosen) = chosen else { return }; + fn open_above(ui: &egui::Ui, nodes: &[FolderNode], chosen: &str) { + for n in nodes { + if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) { + let key = lightcraft_catalog::query::folder_key(&n.path); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(("libfolder-open", key)), true)); + open_above(ui, &n.children, chosen); + } + } + } + open_above(ui, tree, &chosen); +} + +/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that +/// folder. +pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> { + // only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths + // too, so more than one may), and the tree is at most `MAX_DEPTH` deep + tree.iter() + .filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path)) + .find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) }) +} + +pub(super) fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) { + for n in nodes { + let key = lightcraft_catalog::query::folder_key(&n.path); + let open_id = egui::Id::unique(("libfolder-open", key.clone())); + // a disk starts open, and so does a folder that holds nothing itself and leads to one + // folder (`Users` → `me`): the first row where the library branches is what you look for + let leads_on = n.own == 0 && n.children.len() == 1; + let mut open = tree_open(ui, open_id, n.volume || leads_on); + // a row whose path would cover other disks' photos too only opens and closes + let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; + let id = format!("libfolder:{}", n.path); + if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) { + folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); + } + if open && !n.children.is_empty() { + folder_rows(app, ui, &n.children, indent + INDENT_STEP); + } + } +} + +/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's. +#[allow(clippy::too_many_arguments)] +fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) { + let sel = n.selectable + && app.session.source == LibrarySource::LibraryFolder + && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); + // the label is said with the name, as it is shown beside it + let spoken = match n.label { + Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), + 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); + folder_drop_target(app, ui, &resp, n, open_id, open); + let mut toggled = false; + if !n.children.is_empty() { + let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); + toggled = tr.clicked(); + // the triangle sits on the row and takes its clicks: the menu opens from it too + row_menu(app, &tr, n); + } + let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; + let resp = resp.on_hover_text(tip); + if resp.clicked() && !toggled { + if n.selectable { + // a source like an album or a Local folder: it replaces what the grid showed + let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); + } else { + *open = !*open; + set_tree_open(ui, open_id, *open); + } + } + row_menu(app, &resp, n); +} + +/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it +/// can be reached. +const SPRING_OPEN_SECONDS: f64 = 0.7; + +/// A Folders row while photos are dragged from the grid: a release moves their files there on +/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where +/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, +/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, +/// not in the library) and a release says why; photos already there say so. +fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + if !pointer_over_row(ui, resp.rect) { + return; + } + spring_open(ui, n, open_id, open); + let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); + let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); + let refused = if !n.selectable { + Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) + } else if photos.iter().any(|p| p.local) { + Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) + } else { + None + }; + let here = |p: &lightcraft_catalog::Photo| match &p.source { + lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), + _ => false, + }; + let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); + let count = photos.len(); + let released = ui.input(|i| i.pointer.any_released()); + let hint = match (&refused, all_here) { + (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), + (None, true) => crate::i18n::tr_format!("Already in “{}”", name), + (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), + }; + crate::panels::grid::drag_hint(ui.ctx(), hint); + if let Some(why) = refused { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), why); + app.ui.dragging_photos = None; + } + return; + } + if all_here { + if released { + app.ui.dragging_photos = None; + } + return; + } + 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 { + app.ui.dragging_photos = None; + // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person + // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once + if app.ui.settings.ask_before_move { + let library = app.session.library_generation(); + app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); + } else { + move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); + } + } +} + +/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders +/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after +/// leaving it (or after another drag). +fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + if *open || n.children.is_empty() { + return; + } + let key = egui::Id::unique("folder-spring-open"); + let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); + // held without a break: over this row last frame too + let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { + Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, + _ => now, + }; + ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); + if now - since >= SPRING_OPEN_SECONDS { + *open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); + } else { + // keep the clock running while the pointer rests + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). +/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches +/// photos no longer on screen. How many moved, or why none did. +pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result { + let mut params = json!({"folder": path}); + if let Some(ids) = ids { + params["ids"] = json!(ids); + } + let r = app.run("photo.moveToFolder", params)?; + let visible: std::collections::HashSet = app.session.visible_cloned().into_iter().collect(); + let sel = &mut app.session.selection; + sel.ids.retain(|id| visible.contains(id)); + if sel.active.is_some_and(|a| !visible.contains(&a)) { + sel.active = sel.ids.first().copied(); + } + Ok(r["moved"].as_u64().unwrap_or(0)) +} + +/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them +/// back, or that they were there already. +pub(crate) fn moved_text(n: u64, name: &str) -> String { + if n == 0 { + crate::i18n::tr_format!("Already in “{}”", name) + } else { + crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) + } +} + +/// [`move_photos`], saying how it went in a toast. +fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { + match move_photos(app, ids, path) { + Ok(n) => app.toast(ctx, moved_text(n, name)), + Err(e) => app.toast(ctx, e), + } +} + +/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the +/// startup disk. +fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { + if !n.volume { + folder_menu_for_library(app, resp, n); + } else if n.path != "/" { + crate::widgets::context_menu(resp, |ui| { + folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); + synchronize_item(app, ui, n, &n.name); + ui.separator(); + if ui + .button(crate::i18n::tr("Remove Disk from Library…")) + .on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched")) + .clicked() + { + app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: n.path.clone(), name: n.name.clone(), count: n.count, disk: true }); + ui.close(); + } + }); + } +} + +/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing +/// background finder relink only this folder's photos (including the folders inside it). +fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let r = ui.button(crate::i18n::tr("Locate Missing Files…")); + register(ui, "folderLocateMissing", r.rect); + if r.clicked() { + if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + +/// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows: +/// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder +/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the +/// cached Folders tree only; the disk is not touched. +fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> { + use lightcraft_catalog::query::folder_within; + fn exact<'a>(tree: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> { + tree.iter().find_map(|n| if same_folder(&n.path, path) { Some(n) } else { exact(&n.children, path) }) + } + fn any_inside(tree: &[FolderNode], path: &str) -> bool { + tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path)) + } + if let Some(n) = exact(tree, path).filter(|n| n.count > 0) { + return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path))); + } + if !any_inside(tree, path) { + return None; + } + let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let node = FolderNode { + name: name.clone(), + path: path.to_string(), + count: 0, + own: 0, + volume: false, + selectable: false, + label: None, + children: Vec::new(), + }; + Some((node, name)) +} + +/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which +/// scans the folder in the background. +fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { + let r = ui + .button(crate::i18n::tr("Synchronize Folder…")) + .on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps")); + crate::widgets::register(ui, "folderSynchronize", r.rect); + if r.clicked() { + // a disk, or a folder holding disks, was chosen as such + if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + +/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, +/// its colours `folderLabel:`). +fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| { + if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { + if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); + register(ui, "folderLabelMenu", menu.response.rect); +} + +/// The context menu of a folder row: the folder's disk actions (the same as Local's, photos +/// follow) and taking its photos out of the library. +fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { + let path = n.path.as_str(); + let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + // what dialogs call it: the last two names, so same-named folders are told apart + let label = lightcraft_catalog::folders::folder_label(path); + crate::widgets::context_menu(resp, |ui| { + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("Rename “{name}”", name = label), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.clone(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + ui.close(); + } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("folder.move", json!({"path": path, "into": into})) { + Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])), + Err(e) => app.toast(ui.ctx(), e), + } + } + ui.close(); + } + if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), + hint: "Folder name (created on disk)".into(), + value: String::new(), + command: "folder.create".into(), + params: json!({"parent": path}), + key: "name".into(), + }); + ui.close(); + } + let selected = app.session.selection.ids.len(); + if selected > 0 + && ui + .button(crate::i18n::tr("Move Selected Photos Here")) + .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) + .clicked() + { + move_photos_here(app, ui.ctx(), None, path, &name); + ui.close(); + } + if app.services.reveal.is_some() + && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() + && let Some(f) = app.services.reveal.as_mut() + { + let _ = f(path); + ui.close(); + } + folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); + synchronize_item(app, ui, n, &label); + ui.separator(); + if ui + .button(crate::i18n::tr("Remove from Library…")) + .on_hover_text(crate::i18n::tr("Moves the photos imported from this folder to Recently Deleted; no file is touched")) + .clicked() + { + app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: path.to_string(), name: label.clone(), count: n.count, disk: false }); + ui.close(); + } + }); +} diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs new file mode 100644 index 000000000..81997f2e2 --- /dev/null +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -0,0 +1,55 @@ +use super::dates::browse_all_photos; +use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; +use super::*; + +/// "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 +/// context menu renames, merges or deletes the keyword across the library. +pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { + for n in nodes { + // leaves (most keywords) have no open state to look up + let open_id = (!n.children.is_empty()).then(|| egui::Id::unique(("kw-open", n.path.to_lowercase()))); + let mut open = open_id.is_some_and(|k| tree_open(ui, k, false)); + let id = format!("keyword:{}", n.path); + if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) { + keyword_row(app, ui, n, &id, indent, open_id, &mut open); + } + if open { + keyword_rows(app, ui, &n.children, indent + INDENT_STEP); + } + } +} + +/// 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); + if let Some(k) = open_id { + tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); + } + let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(n.path) }; + browse_all_photos(app, !sel); + let _ = app.run("library.filter", json!({"keyword": v})); + } + crate::widgets::context_menu(&resp, |ui| { + let has_sel = app.session.active().is_some(); + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); + } + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); + } + if ui.button(crate::i18n::tr("Merge into…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); + } + if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { + let _ = app.run("keyword.delete", json!({"keyword": n.path})); + } + }); +} diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs new file mode 100644 index 000000000..0fedcb2b5 --- /dev/null +++ b/crates/ui-egui/src/panels/left/local.rs @@ -0,0 +1,406 @@ +use super::folders::folder_tree; +use super::rows::row; +use super::*; + +/// Local's folders as they stand this frame, and the folder being browsed among them. +pub(super) struct LocalNow { + pub(super) places: LocalPlaces, + current: Option, + /// The places removed from Local, as (name, path), in the order they were hidden. + hidden: Vec<(String, String)>, +} + +/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, +/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse +/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this +/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web). +pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { + if !SidebarSection::Local.available() { + return None; + } + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); + let mut builtin: Vec<(String, String)> = Vec::new(); + if !home.is_empty() { + for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] { + // joined with the platform's separator, like the paths browsing gives back + let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }; + // (checked off the UI thread: a home folder can be on a network share) + if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) { + builtin.push((crate::i18n::tr(name).to_string(), p)); + } + } + } + let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); + let current = browsing.as_ref().map(|b| b.path.clone()); + // a hidden built-in place keeps its name (Home, not the user's folder name) + let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); + let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); + let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); + if current.is_some() { + app.ui.local_browse_root = places.browse_root.clone(); + } + Some(LocalNow { places, current, hidden }) +} + +/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. +pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { + let (local, current) = (&now.places, now.current.as_deref()); + for (i, (name, path)) in local.places.iter().enumerate() { + let transient = local.browse_root.as_deref() == Some(path.as_str()); + 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() + { + 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})) { + // the picked folder stays in Local (and comes back if it was hidden) + Ok(r) => { + let dir = r["path"].as_str().unwrap_or(&path).to_string(); + let _ = app.run("local.addRoot", json!({"path": dir})); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + } + // the way back for hidden places: one comes back from the row itself; several are listed, so + // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) + 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) + .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() { + let _ = app.run("local.restoreHidden", json!({"path": only})); + } + } else { + egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); + } + } +} + +/// The list behind "Show N hidden locations": each place by name (its path on hover), then +/// Restore All. +fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { + for (name, path) in hidden { + let r = ui.button(name).on_hover_text(path); + register(ui, format!("local:restoreHidden:{path}"), r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": path})); + ui.close(); + } + } + ui.separator(); + let r = ui.button(crate::i18n::tr("Restore All")); + register(ui, "local:restoreHidden:all", r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({})); + ui.close(); + } +} + +/// Whether two paths name the same folder, however they are spelled (separators, trailing +/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). +pub(crate) fn same_folder(a: &str, b: &str) -> bool { + a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b) +} + +/// Local's top-level folders and how the folder being browsed sits among them. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct LocalPlaces { + /// (label, path) of each top-level folder, in order. + pub places: Vec<(String, String)>, + /// The top-level folder the browsed folder lies in (the innermost one): its tree opens on + /// the way down to it. None when that way passes through a hidden folder. + pub owner: Option, + /// A folder listed only for this session because the browsed folder is in no saved + /// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it. + pub browse_root: Option, +} + +fn folder_label(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) +} + +/// Local's top-level folders, in order: the built-in places, the folders kept with Browse +/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those — +/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else +/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the +/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed +/// once however its path is spelled (the first spelling wins), and hidden ones are left out — +/// they stay reachable through breadcrumbs and Browse Folder…. +pub(crate) fn local_places( + builtin: Vec<(String, String)>, + saved: &[String], + browsing: Option<&str>, + browse_root: Option<&str>, + hidden: &[String], +) -> LocalPlaces { + use lightcraft_catalog::query::{folder_key, folder_within}; + let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p)); + let mut places = builtin; + for path in saved { + if !places.iter().any(|(_, p)| same_folder(p, path)) { + places.push((folder_label(path), path.clone())); + } + } + places.retain(|(_, p)| !is_hidden(p)); + let mut out = LocalPlaces::default(); + if let Some(c) = browsing + && !places.iter().any(|(_, p)| folder_within(c, p)) + { + let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c); + if !is_hidden(root) { + places.push((folder_label(root), root.to_string())); + out.browse_root = Some(root.to_string()); + } + } + if let Some(c) = browsing { + // A tree never opens on the way down through a hidden folder: hiding a kept folder + // beneath Home would otherwise reveal it again inside Home's (possibly huge) tree. + let through_hidden = |p: &str| hidden.iter().any(|h| folder_within(c, h) && folder_within(h, p)); + out.owner = places + .iter() + .enumerate() + .filter(|(_, (_, p))| folder_within(c, p) && !through_hidden(p)) + .max_by_key(|(_, (_, p))| folder_key(p).len()) + .map(|(i, _)| i); + } + out.places = places; + out +} + +/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every +/// 2 s per folder (empty until the first listing). +pub(super) fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { + fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default() +} + +fn list_subfolders(path: &str) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = std::fs::read_dir(path) + .map(|rd| { + rd.flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + (!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string())) + }) + .collect() + }) + .unwrap_or_default(); + v.sort_by_key(|(n, _)| n.to_lowercase()); + v +} + +/// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and +/// the sidebar below them moves) when they land, so the headless driver counts them as pending +/// work and waits for them before acting on widget positions. +pub(crate) fn fs_cached_running(ctx: &egui::Context) -> usize { + fs_running_counter(ctx).load(std::sync::atomic::Ordering::Acquire) +} + +fn fs_running_counter(ctx: &egui::Context) -> std::sync::Arc { + ctx.data_mut(|d| d.get_temp_mut_or_default::>(egui::Id::unique("fs-cached-running")).clone()) +} + +/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker +/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a +/// spinning-up drive never blocks a frame. `None` until the first answer arrives. +pub(crate) fn fs_cached(ui: &egui::Ui, kind: &'static str, path: &str, every: f64, f: fn(&str) -> T) -> Option { + struct Entry { + value: Option, + at: Option, + running: bool, + } + type Cell = std::sync::Arc>>; + let id = egui::Id::unique(("fs-cached", kind, path.to_string())); + let now = ui.input(|i| i.time); + let cell: Cell = match ui.data(|d| d.get_temp::>(id)) { + Some(c) => c, + None => { + let c: Cell = std::sync::Arc::new(std::sync::Mutex::new(Entry { value: None, at: None, running: false })); + ui.data_mut(|d| d.insert_temp(id, c.clone())); + c + } + }; + let lock = |c: &Cell| c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).value.clone(); + let start = { + let mut e = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let due = !e.running && e.at.is_none_or(|at| now - at >= every || now < at); + if due { + e.running = true; + e.at = Some(now); + } + due + }; + if start { + use std::sync::atomic::Ordering; + let running = fs_running_counter(ui.ctx()); + running.fetch_add(1, Ordering::AcqRel); + let (out, path, repaint, done) = (cell.clone(), path.to_string(), ui.ctx().clone(), running.clone()); + let work = move || { + let v = f(&path); + let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + e.value = Some(v); + e.running = false; + drop(e); + // after the answer is stored: a frame that sees the count drop also sees the answer + done.fetch_sub(1, Ordering::AcqRel); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() { + cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false; + running.fetch_sub(1, Ordering::AcqRel); + } + #[cfg(target_arch = "wasm32")] + work(); + } + lock(&cell) +} + +#[cfg(test)] +mod tests { + use super::local_places; + use crate::widgets::elide_head; + + /// Width = characters, so a limit of 12 is "12 characters". + fn fit(text: &str, max: usize) -> String { + elide_head(text, max as f32, |s| s.chars().count() as f32) + } + + #[test] + fn a_label_that_fits_is_left_alone() { + assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); + } + + #[test] + fn a_long_path_loses_its_leading_folders_not_its_end() { + assert_eq!(fit("/Users/me/Pictures/Lightroom", 20), "…/Pictures/Lightroom"); + assert_eq!(fit("/Users/me/Pictures/Lightroom", 12), "…/Lightroom"); + } + + #[test] + fn a_single_name_too_long_is_cut_at_the_end() { + assert_eq!(fit("2024-summer-holiday", 8), "2024-su…"); + assert_eq!(fit("2024-summer-holiday", 1), "…"); + assert_eq!(fit("2024-summer-holiday", 0), "…"); + } + + #[test] + fn multibyte_names_are_cut_on_character_boundaries() { + assert_eq!(fit("/写真/夏休み旅行の記録", 6), "夏休み旅行…"); + } + + fn names(v: &[(String, String)]) -> Vec<&str> { + v.iter().map(|(n, _)| n.as_str()).collect() + } + + /// A built-in location browsed (or picked) under another spelling of its path is the same + /// row, not a second root — for every built-in, not just named ones. + #[test] + fn equivalent_paths_are_one_local_location() { + for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] { + let builtin: Vec<(String, String)> = + ["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect(); + for (_, p) in builtin.clone() { + // the browsed spelling: other separators, a trailing one, a different drive-letter case + let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" }); + for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] { + let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]); + assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}"); + assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted"); + } + // hiding under one spelling hides the other + let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped)); + assert_eq!(l.places.len(), 2, "hidden {flipped}"); + } + } + // a different folder is still added + let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]); + assert_eq!(names(&l.places), ["Pictures", "Pictures2"]); + } + + /// A folder inside a kept root is shown inside that root's tree: the root stays (its other + /// subfolders stay reachable) and no second root appears; other kept roots stay too. + #[test] + fn browsing_below_a_kept_root_keeps_the_root() { + let photos = "/data/Photos".to_string(); + let other = "/data/Scans".to_string(); + let saved = [photos.clone(), other.clone()]; + let builtin = || vec![("Home".to_string(), "/home/example".to_string())]; + for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] { + let l = local_places(builtin(), &saved, Some(browsing), None, &[]); + let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect(); + if browsing.starts_with("/data") { + assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}"); + assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}"); + assert_eq!(l.browse_root, None); + } else { + assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row"); + } + } + // the innermost containing root is the one that opens + let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]); + assert_eq!(l.owner, Some(1)); + } + + /// A browsed folder outside every kept root gets a session row, which stays while browsing + /// below it and gives way when browsing moves elsewhere. + #[test] + fn session_root_stays_while_browsing_below_it() { + let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]); + assert_eq!(l.browse_root.as_deref(), Some("/t/base")); + let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]); + assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}"); + let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]); + assert_eq!(l.browse_root.as_deref(), Some("/elsewhere")); + // hidden: no row at all + let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]); + assert!(l.places.is_empty() && l.browse_root.is_none()); + } + + /// A hidden folder inside a listed one (a kept folder beneath Home) is not revealed in that + /// one's tree while it is browsed, nor are the folders below it; hiding Home itself still + /// lets Pictures open down to a folder browsed inside it. + #[test] + fn hidden_folder_is_not_revealed_in_an_outer_tree() { + let builtin = || vec![("Pictures".to_string(), "/home/example/Pictures".to_string()), ("Home".to_string(), "/home/example".to_string())]; + let kept = ["/home/example/AppData/Temp/lc".to_string()]; + let l = local_places(builtin(), &kept, Some("/home/example/AppData/Temp/lc"), None, &[]); + assert_eq!(l.owner, Some(2), "shown as its own kept row"); + for browsing in ["/home/example/AppData/Temp/lc", "/home/example/AppData/Temp/lc/Day 1"] { + let l = local_places(builtin(), &kept, Some(browsing), None, &["/home/example/AppData/Temp/lc/".into()]); + assert_eq!(names(&l.places), ["Pictures", "Home"], "{browsing}"); + assert_eq!((l.owner, l.browse_root.as_deref()), (None, None), "Home does not open down to {browsing}"); + } + let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]); + assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); + } + + #[test] + fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { + use crate::Services; + use crate::headless::HeadlessView; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut view = HeadlessView::new(); + let path = "/lightcraft-test/Pictures"; + let mut widgets = vec![]; + 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::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); + }); + widgets = crate::widgets::take_registry(&view.ctx); + } + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; + let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}"))); + assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does"); + let triangle = rect(&format!("folderToggle:{path}")); + // the icon is 16 points wide and ends 8 points before the name + assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}"); + } +} diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs new file mode 100644 index 000000000..541d98b06 --- /dev/null +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -0,0 +1,255 @@ +use super::albums::MARK_W; +use super::*; + +/// 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 +/// exceeds the panel. One frame behind, which needs no second layout pass. +pub(crate) fn content_width(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(egui::Id::unique("left-content-width"))).unwrap_or(0.0) +} + +/// The most a row asks for its name: longer ones are cut when there is no more room. +const MAX_NAME_NEED: f32 = 140.0; + +/// Height of a sidebar row. +pub(super) const ROW_HEIGHT: f32 = 29.0; + +/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to +/// this frame: set before the sections are drawn, cleared after. +pub(super) const SCROLL_TO_ROW: &str = "left-scroll-to-row"; + +/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]): +/// removed, they open again. +pub(super) const LIBFOLDER_REVEALED: &str = "libfolder-revealed"; + +/// A row says how wide it needs to be. +pub(super) fn note_width(ui: &egui::Ui, w: f32) { + ui.data_mut(|d| { + let m = d.get_temp_mut_or_insert_with::(egui::Id::unique("left-content-width-next"), || 0.0); + *m = m.max(w); + }); +} + +/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps. +pub(crate) const INDENT_STEP: f32 = 16.0; + +/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's. +pub(crate) const MIN_INDENT_STEP: f32 = 8.0; + +/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to +/// less than this while a narrower step would still keep it. +const MIN_NAME_ROOM: f32 = 80.0; + +/// Room kept for a count at the end of the deepest row (three digits and their gap). +const COUNT_ROOM: f32 = 40.0; + +/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the +/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame. +pub(crate) fn indent_step(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(egui::Id::unique("left-indent-step"))).unwrap_or(INDENT_STEP) +} + +/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame. +pub(super) fn indent_x(ctx: &egui::Context, indent: f32) -> f32 { + indent / INDENT_STEP * indent_step(ctx) +} + +/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next +/// frame's step. +fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 { + ui.data_mut(|d| { + let m = d.get_temp_mut_or_insert_with::(egui::Id::unique("left-deepest-indent-next"), || 0.0); + *m = m.max(indent); + }); + indent_x(ui.ctx(), indent) +} + +/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s) +/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a +/// row does not slide sideways at every point the panel is dragged; when even the narrowest step +/// does not fit, the sidebar scrolls sideways as before. +pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { + let levels = deepest / INDENT_STEP; + if levels < 1.0 { + return INDENT_STEP; + } + let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM; + [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, + 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>, + count: Option, + selected: bool, + indent: f32, + sense: Sense, + mark: Option, +) -> egui::Response { + let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; + let t = Tokens::get(ui.ctx()); + let indent = drawn_indent(ui, indent); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); + if scroll_asked(ui, id) { + ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); + } + register(ui, format!("source:{id}"), r); + let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; + let said = spoken.unwrap_or(label); + let name = match count { + Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n), + None => said.to_string(), + }; + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &name)); + // what is visible of the row: when the sidebar is scrolled sideways its bars and counts end at + // the panel's edge, not at the end of the (wider) content + let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX); + let edge = r.right().min(visible_right); + let inner = Rect::from_min_max(r.min + vec2(8.0, 0.0), pos2((r.right() - 8.0).min(edge - 8.0).max(r.left() + 8.0), r.bottom())); + if selected { + ui.painter().rect_filled(inner, 4.0, t.canvas); + register(ui, format!("highlight:{id}"), inner); + } else if resp.hovered() { + ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6)); + } + paint( + ui.painter(), + Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)), + icon, + if selected { t.text } else { t.icon }, + ); + let font = t.font(13.5); + let color = if selected { t.text } else { t.text_label }; + // a count of nothing is there, but fainter: the rows that hold photos stand out + let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim }; + let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n))); + let label_left = r.left() + 42.0 + indent; + let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); + // a label's dot sits just before the count + if let Some(c) = mark { + let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0)); + ui.painter().circle_filled(dot.center(), 4.0, c); + register(ui, format!("labelMark:{id}"), dot); + } + // the name gives way to the count: cut with an ellipsis, in full on hover + let room = count_left - mark_w - 8.0 - label_left; + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; + let full_w = measure(label); + let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) }; + let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); + register(ui, format!("label:{id}"), label_rect); + let resp = if shown != label { resp.on_hover_text(label) } else { resp }; + note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x))); + if let Some(galley) = count_galley { + let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size()); + register(ui, format!("count:{id}"), rect); + ui.painter().galley(rect.min, galley, t.text_dim); + } + resp +} + +/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed +/// for the control channel and asks for its width (so the content does not widen and narrow as +/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders, +/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is +/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`]. +pub(super) fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option, indent: f32, mark: bool) -> bool { + let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); + if ui.is_rect_visible(r) || scroll_asked(ui, id) { + return false; + } + ui.allocate_space(r.size()); + register(ui, format!("source:{id}"), r); + let indent = drawn_indent(ui, indent); + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; + let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x); + note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w)); + true +} + +/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]). +fn scroll_asked(ui: &egui::Ui, id: &str) -> bool { + ui.data(|d| d.get_temp::(egui::Id::unique(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase()) +} + +/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for +/// room for its name up to a share of a panel, so one very long name does not make everything +/// scroll: depth does that. +fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 { + 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) +} + +/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing +/// when closed, down when open, registered as widget `widget` for the control channel. Returns +/// its response: the caller flips the open state on `clicked()`. +fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); + let tr = ui.interact(tri, id, Sense::click()); + register(ui, widget, tri); + let col = if tr.hovered() { t.text } else { t.text_dim }; + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); + tr +} + +/// Whether the tree row `key` is open: what the user last chose this session, else `default`. +pub(super) fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool { + ui.data(|d| d.get_temp(key)).unwrap_or(default) +} + +/// Open or close the tree row `key` from now on. +pub(super) fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { + ui.data_mut(|d| d.insert_temp(key, open)); +} + +/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, +/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response +/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). +pub(super) fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { + let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget); + if tri.clicked() { + *open = !*open; + set_tree_open(ui, key, *open); + } + let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }; + tri.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + tri +} diff --git a/crates/ui-egui/src/panels/left/sections.rs b/crates/ui-egui/src/panels/left/sections.rs new file mode 100644 index 000000000..42cc631b5 --- /dev/null +++ b/crates/ui-egui/src/panels/left/sections.rs @@ -0,0 +1,419 @@ +use super::albums::top_level_drop_target; +use super::rows::{ROW_HEIGHT, note_width}; +use super::*; + +/// Space above a section's header. +pub(super) const SECTION_GAP: f32 = 10.0; + +/// Height of a section's header. +const SECTION_HEADER: f32 = 34.0; + +/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` +/// draws. Every section goes through here, so they all fold, space and announce themselves alike. +/// `rows`: how many rows the section lists at its top level (a folded header says so). +pub(super) fn section( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + section: SidebarSection, + rows: usize, + body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), +) -> PlacedSection { + ui.add_space(SECTION_GAP); + let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); + // a hairline in the gap above tells the sections apart + let t = Tokens::get(ui.ctx()); + let edge = visible_right(ui).min(header.right()); + let y = header.top() - SECTION_GAP / 2.0; + ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); + let show = egui::Id::unique("left-show-section"); + if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show)) + && asked == section + { + ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show)); + // asked for by the frame before (a request the section was not drawn for is forgotten), + // and up or down only: the sidebar stays where it was scrolled sideways + if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 { + let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-view-top"))).unwrap_or(header.top()); + let delta = vec2(0.0, view_top - header.top()); + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } + let open = section_header(app, ui, section, header, rows, false).open; + if open { + let rows_from = ui.cursor().top(); + body(app, ui); + if ui.cursor().top() <= rows_from { + empty_hint(ui, section); + } + } + PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows } +} + +/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider +/// than the panel while the sidebar scrolls sideways. +fn visible_right(ui: &egui::Ui) -> f32 { + ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX) +} + +/// The header of the open section whose own header has scrolled out of sight stays at the top of +/// the sidebar (widget `sidebarSectionPinned:`), so it is always clear which section the rows +/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The +/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. +pub(super) fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { + let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top); + let rect = pinned.map(|p| { + let top = view_top.min(p.bottom - SECTION_HEADER); + Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER) + }); + // rows ask where it is (next frame: they are laid out before it) + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-pinned-rect"), rect)); + let (Some(p), Some(r)) = (pinned, rect) else { return }; + let t = Tokens::get(ui.ctx()); + let view = ui.clip_rect(); + // over the rows scrolling beneath it + ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome); + ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider)); + if section_header(app, ui, p.section, r, p.rows, true).toggled { + // folded from here: its own header comes into view, not whatever was below the section + // (next frame, when the section is laid out folded: see `section`) + let frame = ui.ctx().cumulative_frame_nr(); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-show-section"), (p.section, frame))); + ui.ctx().request_repaint(); + } +} + +/// What an open section with nothing to list says in place of its rows (widget +/// `sidebarEmpty:`), so the section stays where it is instead of disappearing. +fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover()); + register(ui, format!("sidebarEmpty:{}", section.id()), r); + let text = crate::i18n::tr(section.empty_hint()); + let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); + note_width(ui, 42.0 + galley.size().x + 18.0); + ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim); +} + +/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PlacedSection { + pub section: SidebarSection, + pub top: f32, + pub bottom: f32, + /// Not folded: its rows (or its note that there are none) are drawn. + pub open: bool, + /// How many rows it lists at its top level. + pub rows: usize, +} + +/// What a section's header says happened to it this frame. +struct HeaderOutcome { + open: bool, + /// A click folded or unfolded the section. + toggled: bool, +} + +/// Where a section dragged to `y` would go among the sections drawn: above the first one whose +/// middle is below `y` (`Some`), or after them all (`None`). +pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option { + placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section) +} + +/// While a section is dragged by its header: a line shows where it would go, releasing over the +/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it. +pub(super) fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) { + let Some(dragged) = app.ui.dragging_section else { return }; + let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if esc { + // the drag's Esc, not also a dialog's or a view's + crate::widgets::take_escape(ui.ctx()); + app.ui.dragging_section = None; + return; + } + let view = ui.clip_rect(); + let Some(pos) = pos.filter(|p| view.contains(*p)) else { + if released { + app.ui.dragging_section = None; + } + return; + }; + let before = section_drop_before(placed, pos.y); + if released { + app.ui.dragging_section = None; + let before = before.map_or(serde_json::Value::Null, |b| json!(b.id())); + if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) { + app.toast(ui.ctx(), e); + } + return; + } + // the line: in the gap above the section it would go before, or below the last one + let y = match before { + Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0), + None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0), + }; + if let Some(y) = y { + let t = Tokens::get(ui.ctx()); + let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0)); + ui.painter().rect_filled(line, 1.0, t.accent); + register(ui, "sidebarSectionDrop", line); + } +} + +/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the +/// sidebar, or with the sidebar closed) is over. +pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(section) = app.ui.dragging_section else { return }; + let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); + if !down || !app.ui.left_panel { + app.ui.dragging_section = None; + return; + } + let Some(pos) = pos else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::unique("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }, + ); +} + +/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their +/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at +/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo +/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). +fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { + let t = Tokens::get(ui.ctx()); + let id = section.id(); + let title = crate::i18n::tr(section.title()); + let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") }; + let resp = ui.interact(r, ui.scope_id().with((interact, id)), Sense::click_and_drag()); + register(ui, format!("{widget}:{id}"), r); + let toggled = resp.clicked(); + if toggled { + app.ui.sidebar.toggle_collapsed(section); + } + // dragged by its header, a section moves among the others (see `section_drag`); a press on + // the header's button is the button's, also when it turns into a drag + let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p)); + if resp.drag_started_by(egui::PointerButton::Primary) && !on_button { + // picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`) + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-section-drag-from-pinned"), pinned)); + app.ui.dragging_section = Some(section); + } + crate::widgets::context_menu(&resp, |ui| sections_menu(app, ui, Some(section))); + let open = !app.ui.sidebar.is_collapsed(section); + crate::widgets::announce_disclosure(&resp, true, open, title); + // what is visible of the header: its bar and count end at the panel's edge, like a row's + let edge = r.right().min(visible_right(ui)); + let hovered = resp.hovered() && app.ui.dragging_section.is_none(); + if hovered { + let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0)); + ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); + } + let c = pos2(r.left() + 10.0, r.center().y); + register(ui, format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0))); + let col = if hovered { t.text } else { t.text_dim }; + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); + ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); + let button = header_button(section); + // (an album being dragged puts "Top Level" in the Albums header) + let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some()); + if !open && rows > 0 && app.ui.show_counts && room { + // folded: how much is in there (left of the header's own button) + let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 }; + let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); + register(ui, format!("{widget}Count:{id}"), count); + } + if section == SidebarSection::Albums { + top_level_drop_target(app, ui, r); + } + if let Some(button) = button { + show_header_button(app, ui, r, &button); + } + HeaderOutcome { open, toggled } +} + +/// Side of a header's button. +const HEADER_BUTTON: f32 = 26.0; + +/// A button at the end of a section's header (see [`header_button`]). +struct HeaderButton { + /// Its widget is `icon:`. + id: &'static str, + icon: Icon, + /// What it does, in English: its tooltip and spoken name. + tooltip: &'static str, + action: HeaderAction, +} + +/// What a header's button does when clicked. +enum HeaderAction { + /// Does it at once (usually: opens a dialog). + Run(fn(&mut LightcraftApp, &egui::Context)), + /// Opens a menu with these items. + Menu(fn(&mut LightcraftApp, &mut egui::Ui)), +} + +/// The button a section has at the end of its header, if it has one. This is the one place that +/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps +/// its clicks from folding the section and leaves it room beside a folded section's count. +fn header_button(section: SidebarSection) -> Option { + match section { + SidebarSection::Albums => { + Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) }) + } + SidebarSection::Keywords => { + Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) }) + } + SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None, + } +} + +/// The end of `header` kept for its button: at the visible edge, also when the sidebar is +/// scrolled sideways. +fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect { + let right = visible_right(ui).min(header.right()); + Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom())) +} + +/// Draw `button` at the end of `header` and act on it. +fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) { + let mut hdr = + ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).layout(egui::Layout::right_to_left(egui::Align::Center))); + let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip); + match button.action { + HeaderAction::Run(run) => { + if resp.clicked() { + run(app, ui.ctx()); + } + } + HeaderAction::Menu(items) => { + egui::Popup::menu(&resp).show(|ui| items(app, ui)); + } + } +} + +/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside +/// whatever is picked in the Keyword List, which may not even be on screen). +/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`). +fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) { + let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None); + if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog { + *reveal = true; + } + app.ui.dialog = Some(dialog); +} + +/// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, +/// show or hide any section, and go back to the usual arrangement. Its items are widgets +/// `sidebarMenu:hide`, `sidebarMenu:section:` and `sidebarMenu:reset`. +pub(super) fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option) { + let mut run = None; + if let Some(section) = on { + let hide = ui.button(crate::i18n::tr("Hide This Section")); + register(ui, "sidebarMenu:hide", hide.rect); + if hide.clicked() { + run = Some(("view.sidebarSection", json!({"section": section.id(), "show": false}))); + } + for (up, label, widget) in [(true, "Move Up", "sidebarMenu:moveUp"), (false, "Move Down", "sidebarMenu:moveDown")] { + let item = ui.add_enabled(app.ui.sidebar.can_move(section, up), egui::Button::new(crate::i18n::tr(label))); + register(ui, widget, item.rect); + if item.clicked() { + run = Some(("view.sidebarMoveSection", json!({"section": section.id(), "direction": if up { "up" } else { "down" }}))); + } + } + ui.separator(); + } + for s in app.ui.sidebar.sections().iter().filter(|s| s.id.available()) { + let shown = ui.selectable_label(!s.hidden, crate::i18n::tr(s.id.title())); + register(ui, format!("sidebarMenu:section:{}", s.id.id()), shown.rect); + if shown.clicked() { + run = Some(("view.sidebarSection", json!({"section": s.id.id(), "show": s.hidden}))); + } + } + ui.separator(); + let reset = ui.add_enabled(!app.ui.sidebar.is_default(), egui::Button::new(crate::i18n::tr("Reset Sidebar Sections"))); + register(ui, "sidebarMenu:reset", reset.rect); + if reset.clicked() { + run = Some(("view.sidebarReset", json!({}))); + } + if let Some((command, params)) = run { + if let Err(e) = app.run(command, params) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + +/// The menu of Albums' +: Create Album, Smart Album, Folder… +fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { + if ui.button(crate::i18n::tr("Create Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }); + } + if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::SmartRules { + id: None, + name: String::new(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: None, + }); + } + if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); + } + if ui.button(crate::i18n::tr("Create Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None, include_selected: false }); + } + // only once the albums were put in an order by hand + if app.session.catalog.album_children_are_ordered(None) { + ui.separator(); + if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { + let _ = app.run("album.sort", json!({})); + } + } +} + +/// How many library photos have no file (Local browse records are not checked; see +/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes +/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed +/// at most every 5 s, and at once (after the running check) when the catalog changed. +pub(super) fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; + #[derive(Clone, Default)] + struct Job { + n: std::sync::Arc, + running: std::sync::Arc, + at: f64, + rev: u64, + } + let id = egui::Id::unique("missing-count"); + let now = ui.input(|i| i.time); + let rev = app.session.catalog.revision; + let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() }); + let fresh = now - job.at < 5.0 && job.rev == rev; + if !fresh && !job.running.load(Relaxed) { + // the same scope as the Missing Photos view: library photos only, never Local browse records + let paths = lightcraft_engine::cmd::missing::candidates(&app.session.catalog); + job.running.store(true, Relaxed); + job.at = now; + job.rev = rev; + let (n, running, ctx) = (job.n.clone(), job.running.clone(), ui.ctx().clone()); + let work = move || { + let missing = if cfg!(target_arch = "wasm32") { 0 } else { paths.iter().filter(|p| !std::path::Path::new(p).exists()).count() }; + n.store(missing, Relaxed); + running.store(false, Relaxed); + ctx.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(work); + #[cfg(target_arch = "wasm32")] + work(); + ui.data_mut(|d| d.insert_temp(id, job.clone())); + } + job.n.load(Relaxed) +} diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 3081bc80c..544e29449 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -24,6 +24,246 @@ use crate::state::{AppSettings, LaunchLibrary}; use crate::theme::Tokens; use crate::widgets::register; +/// Egui ids/layers shared by the window drawing and the control-channel barrier: the two blocking +/// windows and their dim. +const DIM_ID: &str = "library-problem-dim"; +const ERROR_WINDOW_ID: &str = "library-problem"; +const CHOOSER_WINDOW_ID: &str = "library-chooser"; + +/// The named `Foreground` layer used by the dim and the two blocking library windows. +fn foreground_layer(id: &str) -> egui::LayerId { + egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(id)) +} + +/// The full-screen backdrop that takes the modal layer while the library window is up. +pub(crate) fn dim_layer() -> egui::LayerId { + foreground_layer(DIM_ID) +} + +/// Whether `layer` is one of the blocking library windows' own layers (the error window or the +/// launch list). Their registered controls are the only targets allowed through the dim. +fn is_window_layer(layer: egui::LayerId) -> bool { + layer == foreground_layer(ERROR_WINDOW_ID) || layer == foreground_layer(CHOOSER_WINDOW_ID) +} + +/// Whether a control-channel press must be explicitly refused because the library window's dim +/// owns the modal layer and the target is not one of that window's own controls. Ordinary dialogs +/// keep their existing backdrop semantics; only this blocking window is different. +pub(crate) fn refuses_control_target(top_modal: Option, target: egui::LayerId) -> bool { + top_modal == Some(dim_layer()) && !is_window_layer(target) +} + +/// Which blocking window is on screen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WindowMode { + Error, + Chooser, +} + +/// What one draw pass did for the blocking library window (test instrumentation). +/// +/// The first-return tests used to prove shielding only through a rating shortcut, which the global +/// shortcut guard now blocks on every settled blocking frame. Recording the pass itself keeps +/// those regressions non-vacuous: removing the shield pass, its discard or its settlement changes +/// this sequence even though the rating shortcut would still stay blocked. +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LibraryPass { + /// The dedicated shield pass for a newly visible window: only the window drew, background + /// input was dropped, and egui discarded the pass. + Shield, + /// A pass that went on to draw the background (with the visible window, when there is one). + Background, +} + +/// First-frame input state for the blocking library window. +/// +/// Its `Foreground` areas are non-interactable during their first layout pass, and a text field +/// behind it may still own the keyboard focus. The frame that creates a window therefore draws +/// only that window and drops background input, just as a new notice prompt does. +#[derive(Default)] +pub(crate) struct WindowBarrier { + /// The window whose completed layout has already settled. `None` until the first pass is + /// accepted (and again once the window or its banner replaces it). + shown: Option, + /// The next pass 0 is the shielding pass for a newly shown window. Also armed while the + /// window is hidden by a prompt or by a dialog it opened, so the first pass that actually + /// draws it again is shielded rather than reusing a background pass's hit-test snapshot. + transition: bool, + /// The window actually drew in the pass being completed (reset at the start of every draw). + drawn: Option, + /// The shield pass for the current transition has already run (the discarded pass drew only + /// the window); the following pass may settle it. Without this, a window that appeared in the + /// middle of a pass could settle without ever having had a dedicated shield pass. + shielded: bool, + /// The current transition may have dropped an injected synthetic batch; cancel its gesture + /// once so it reports an error instead of a false `ok: true`. + cancel_injected: bool, +} + +impl WindowBarrier { + /// Begin a new shield transition. It stays armed until a completed pass has drawn the window + /// after a dedicated shield pass. + fn begin_transition(&mut self) { + self.transition = true; + self.shielded = false; + self.cancel_injected = true; + } +} + +/// The blocking library window that is present (not dismissed), if any: the launch chooser when +/// it is shown, otherwise the error window. +fn present_mode(app: &LightcraftApp) -> Option { + let problem = app.library_problem.as_ref().filter(|p| !p.dismissed)?; + Some(if problem.chooser.is_some() { WindowMode::Chooser } else { WindowMode::Error }) +} + +/// Whether the open dialog is the launch list's own New Library name prompt. +/// +/// Any other dialog (Settings opened before Alt/Option created the chooser, for example) is not +/// owned by the launch list: the blocking window stays visible over it and keeps owning input, +/// instead of stepping aside for a dialog it did not open. +fn chooser_owns_dialog(app: &LightcraftApp) -> bool { + app.library_problem.as_ref().is_some_and(|p| p.chooser.is_some()) + && matches!( + app.ui.dialog.as_ref(), + Some(crate::state::Dialog::TextPrompt { command, .. }) if command == "app.newLibrary" + ) +} + +/// The window actually drawn this pass. A prompt suppresses it, and the launch list yields only to +/// the New Library name dialog it opened itself. +fn visible_mode(app: &LightcraftApp) -> Option { + let mode = present_mode(app)?; + if crate::panels::notices::blocks_input(app) { + return None; + } + if mode == WindowMode::Chooser && chooser_owns_dialog(app) { + return None; + } + Some(mode) +} + +/// Whether the blocking library window is actually on screen over the rest of the UI this frame, +/// so a background dialog's own raw input handlers must stay still. +pub(crate) fn blocks_dialog_input(app: &LightcraftApp) -> bool { + visible_mode(app).is_some() +} + +/// A window drawn before its barrier saw it (a command late in a draw pass created it) must not +/// settle this pass: arm the transition so the next frame gets the shielded first draw. +fn note_first_draw(app: &mut LightcraftApp, mode: WindowMode) { + if app.library_window_input.shown != Some(mode) && !app.library_window_input.transition { + app.library_window_input.begin_transition(); + } +} + +/// Surrender egui's current keyboard focus, so a background field cannot receive text in the +/// window's first shielded pass. +fn clear_focus(ctx: &egui::Context) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); +} + +/// Note a new blocking window before any panel or shortcut reads this pass's input. Its first +/// frame clears a background text field's focus and drops text, pointer, wheel, keys and +/// accessibility input. `keys_down` is cleared so no key is left held after the discarded pass; +/// touch End/Cancel still reach egui to clear its private touch state. Modifier and window-focus +/// changes also survive, as for a prompt. +pub(crate) fn prepare_ui_pass(app: &mut LightcraftApp, ctx: &egui::Context, batch_in_flight: bool) { + if ctx.current_pass_index() != 0 { + return; + } + // A pass 0 starts a new shield cycle; `shield_pass` records that its dedicated pass ran. + app.library_window_input.shielded = false; + let Some(mode) = visible_mode(app) else { + if app.library_problem.as_ref().is_none_or(|p| p.dismissed) { + app.library_window_input = WindowBarrier::default(); + } else if !app.library_window_input.transition { + // The window is present but a prompt or the dialog it opened hides it. The next pass + // that actually draws it must be shielded: while hidden, its controls are absent from + // the previous pass's widget snapshot, and egui's hit test would belong to the + // background. + app.library_window_input.begin_transition(); + } + return; + }; + if app.library_window_input.shown != Some(mode) { + app.library_window_input.begin_transition(); + } + if !app.library_window_input.transition { + return; + } + // A background text field may have kept or regained focus while the window was hidden by a + // prompt or dialog; the shielded first frame must not let it receive the host's text. + clear_focus(ctx); + if batch_in_flight && std::mem::take(&mut app.library_window_input.cancel_injected) { + // The library barrier below removes this pass's already-injected events before any panel + // sees them. Like the prompt barrier, the gesture that queued them must fail, never report + // a successful delivery of input that was discarded. + app.cancel_gestures_for_barrier(true, "a blocking library window appeared"); + } + ctx.stop_dragging(); + ctx.input_mut(|input| { + // The discarded pass must not leave a key held merely because its release arrived in the + // window's creation frame. + input.keys_down.clear(); + input.pointer = egui::PointerState::default(); + input.smooth_scroll_delta = egui::Vec2::ZERO; + input.events.retain(|event| { + matches!( + event, + egui::Event::ModifiersChanged(_) + | egui::Event::WindowFocused(_) + | egui::Event::Touch { phase: egui::TouchPhase::End | egui::TouchPhase::Cancel, .. } + ) + }); + }); +} + +/// Whether `draw_window` must render only the new blocking window for this first pass. +pub(crate) fn shield_pass(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + let shield = ctx.current_pass_index() == 0 && app.library_window_input.transition && visible_mode(app).is_some(); + if shield { + app.library_window_input.shielded = true; + } + shield +} + +/// Reset the per-pass draw witness before any panel runs. `show` marks the window only when it +/// actually draws it; a prompt/dialog hiding the window must not inherit a previous pass's mark. +pub(crate) fn begin_draw(app: &mut LightcraftApp) { + app.library_window_input.drawn = None; +} + +/// A completed, nondiscarded pass has laid the window out. Only an actual draw after the shield +/// pass settles it; a window hidden by a prompt or by the dialog it opened, or one that appeared +/// midway through this pass (the banner's Open Library… button), keeps the transition armed so +/// the first pass that draws it again is shielded. +pub(crate) fn finish_ui_pass(app: &mut LightcraftApp, ctx: &egui::Context) { + if ctx.will_discard() { + return; + } + if let Some(mode) = visible_mode(app) { + if app.library_window_input.drawn == Some(mode) && (!app.library_window_input.transition || app.library_window_input.shielded) { + app.library_window_input.shown = Some(mode); + app.library_window_input.transition = false; + app.library_window_input.shielded = false; + app.library_window_input.cancel_injected = false; + } else if !app.library_window_input.transition { + app.library_window_input.begin_transition(); + } + } else if app.library_problem.as_ref().is_none_or(|p| p.dismissed) { + app.library_window_input = WindowBarrier::default(); + } else if !app.library_window_input.transition { + // A prompt appeared or changed during the draw and now hides the window. + app.library_window_input.begin_transition(); + } +} + /// How long after the window appears holding Alt/Option still asks (egui time, seconds). The /// modifier keys can't be read before the first frame, and macOS reports one held since before /// launch only once it changes, so it is watched over the first frames. @@ -241,6 +481,24 @@ const CHECK_LABEL: &str = "Checking recent libraries"; struct Answer { why: Option, at: web_time::Instant, + /// The root UI frame that applied this answer ([`egui::Context::cumulative_frame_nr_for`]). + /// Scheduling must not ask about a check answer again in the frame that delivered it, which + /// also covers every layout pass of that frame; `None` for an answer no check worker made + /// (`opened`, or a check that couldn't start). + delivered_frame: Option, +} + +impl Answer { + /// An answer from a check worker, delivered in root UI frame `delivered_frame`. + fn from_check(why: Option, delivered_frame: u64) -> Answer { + Answer { why, at: web_time::Instant::now(), delivered_frame: Some(delivered_frame) } + } + + /// An answer no check worker made, with no publication fence: it may be asked about again on + /// the next frame as before. + fn local(why: Option) -> Answer { + Answer { why, at: web_time::Instant::now(), delivered_frame: None } + } } /// Answers to "can this recent library be opened?", each found on a worker thread of its own (a @@ -321,7 +579,7 @@ impl RecentChecks { /// `path` was just opened (or made): it can be opened. pub fn opened(&mut self, path: &str) { - self.known.insert(path.to_string(), Answer { why: None, at: web_time::Instant::now() }); + self.known.insert(path.to_string(), Answer::local(None)); } /// Opening `path` from the launch window failed. That says nothing about whether it is there @@ -354,7 +612,8 @@ fn start_checks(app: &mut LightcraftApp, paths: Vec) { let path = path.clone(); move |app, ctx, why| { app.recent_checks.pending.remove(&path); - app.recent_checks.known.insert(path, Answer { why, at: web_time::Instant::now() }); + let delivered_frame = ctx.cumulative_frame_nr_for(egui::ViewportId::ROOT); + app.recent_checks.known.insert(path, Answer::from_check(why, delivered_frame)); apply_answers(app); ctx.request_repaint(); } @@ -376,7 +635,7 @@ fn start_checks(app: &mut LightcraftApp, paths: Vec) { // no worker thread: say so on the row, and don't try again every frame Err(e) => { log::warn!("{e}"); - app.recent_checks.known.insert(path, Answer { why: Some(Unavailable::CheckFailed(e)), at: web_time::Instant::now() }); + app.recent_checks.known.insert(path, Answer::local(Some(Unavailable::CheckFailed(e)))); apply_answers(app); } } @@ -394,7 +653,7 @@ fn reap_checks(app: &mut LightcraftApp) { for path in dead { app.recent_checks.pending.remove(&path); let why = Unavailable::CheckFailed(crate::i18n::tr("This library couldn't be checked").to_string()); - app.recent_checks.known.insert(path, Answer { why: Some(why), at: web_time::Instant::now() }); + app.recent_checks.known.insert(path, Answer::local(Some(why))); } apply_answers(app); } @@ -474,6 +733,30 @@ pub fn holds_drops(app: &LightcraftApp) -> bool { app.session.library.is_none() && app.library_problem.as_ref().is_some_and(|p| !p.dismissed) } +/// Whether the blocking library window must hold file drops back. While a library is already +/// open, that window (the launch list over it, or the error of a failed switch) owns input: +/// importing a drop now would add photos, presets or a model dialog behind it. With no library +/// open, the desktop drop handler's [`hold_dropped`] path takes the photos for the library the +/// window opens instead, so the drop still has to be consumed. +/// +/// The launch list hides itself behind its own New Library name prompt (`chooser_owns_dialog`). +/// It still owns the library then: a queued drop must wait for the prompt to close, not be taken +/// and refused by the drop handler's generic open-dialog check. After the prompt, New Library and +/// Open Another… ask for a folder on the host's asynchronous picker; the library opens only when +/// that answer arrives, so the queued drop waits too. With no library open a dialog (or a library +/// picker that will open one) in front of the window would otherwise refuse the drop or consume it +/// before the photos could reach [`hold_dropped`]. +pub fn blocks_file_drops(app: &LightcraftApp) -> bool { + if app.library_problem.as_ref().is_none_or(|p| p.dismissed) { + return false; + } + if app.session.library.is_none() { + let choosing = app.pending_picks.iter().any(|p| p.command == "app.newLibrary" || p.command == "app.openLibrary"); + return app.ui.dialog.is_some() || choosing; + } + true +} + /// Photos dropped on the window while no library is open (the launch list, or the window of a /// library that failed to open): kept with the command line's files and imported once a library /// opens, instead of going into the session the next library replaces. Gives the photos back when @@ -550,7 +833,12 @@ pub fn logic(app: &mut LightcraftApp, ctx: &egui::Context) { log::warn!("import: {e}"); } } + let batch_in_flight = app.synthetic_in_flight; alt_at_launch(app, ctx); + // Alt/Option can create the launch list here, after the frame's input was accepted. Drop + // background text/pointer/keys and give it a shield pass before its own key handler reads them. + // An already injected synthetic batch is dropped by that barrier and must fail its gesture. + prepare_ui_pass(app, ctx, batch_in_flight); reap_checks(app); // the launch list asks for fresh answers once let listed: Option> = @@ -573,6 +861,12 @@ pub fn logic(app: &mut LightcraftApp, ctx: &egui::Context) { let focused = ctx.input(|i| i.focused); let regained = focused && !std::mem::replace(&mut app.recent_checks.focused, focused); let after = app.recent_checks.recheck_after; + // The frame whose UI phase is scheduling now. An answer applied during this frame (by + // `logic_background`'s `tasks::poll`, or a hidden logic tick just before the first visible UI) + // has this number too, so it is shown for at least this whole frame: scheduling it again in + // the same frame would erase it before any completed frame showed it, and a discarded layout + // pass must not change that. Unknown paths have no answer and still start immediately. + let frame = ctx.cumulative_frame_nr_for(egui::ViewportId::ROOT); let stale: Vec = app .ui .settings @@ -581,11 +875,9 @@ pub fn logic(app: &mut LightcraftApp, ctx: &egui::Context) { .take(crate::state::RECENT_LIBRARIES) .filter(|p| { !app.recent_checks.is_checking(p) - && app - .recent_checks - .known - .get(*p) - .is_none_or(|a| (regained || matches!(a.why, Some(Unavailable::NotFound))) && a.at.elapsed() > after) + && app.recent_checks.known.get(*p).is_none_or(|a| { + a.delivered_frame != Some(frame) && (regained || matches!(a.why, Some(Unavailable::NotFound))) && a.at.elapsed() > after + }) }) .cloned() .collect(); @@ -600,7 +892,11 @@ pub fn logic(app: &mut LightcraftApp, ctx: &egui::Context) { } ctx.request_repaint_after(std::time::Duration::from_millis(250)); } - chooser_keys(app, ctx); + // A quit prompt or settings notice sits above the launch list; its Escape/Return must not + // quit or switch libraries behind the prompt. + if !crate::panels::notices::blocks_input(app) { + chooser_keys(app, ctx); + } } /// Alt/Option held during the first [`ALT_SECONDS`] after the window appears opens the launch list @@ -630,10 +926,10 @@ fn alt_at_launch(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.request_repaint(); } -/// The launch list on screen (no dialog over it, e.g. New Library's name). +/// The launch list on screen (not covered by the New Library name dialog it opened). fn chooser_shown(app: &LightcraftApp) -> Option<&Chooser> { let p = app.library_problem.as_ref().filter(|p| !p.dismissed)?; - p.chooser.as_ref().filter(|_| app.ui.dialog.is_none()) + p.chooser.as_ref().filter(|_| !chooser_owns_dialog(app)) } /// ↑ / ↓ move the selection, Return opens it (unless a button has the keyboard focus: it acts @@ -675,33 +971,53 @@ fn select(app: &mut LightcraftApp, at: usize) { } /// Dim everything behind the window; it swallows clicks, so nothing there can be used meanwhile. +/// The dim is `Foreground` and takes the modal layer: a `Middle` dialog such as Settings may be +/// open underneath, and without this the dialog's own modal layer would let its controls receive +/// clicks through the dim. Dialogs are drawn before this window, and a quit prompt or settings +/// notice suppresses this window entirely (`show` returns early), so neither is covered by it. +/// Pressing the dim raises its area, so each window is moved back above it after being shown. fn dim(ctx: &egui::Context) { let screen = ctx.content_rect(); - egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { + let dim = egui::Area::new(egui::Id::unique(DIM_ID)).order(egui::Order::Foreground).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { + // swallows clicks: nothing behind the window can be used meanwhile ui.allocate_rect(screen, egui::Sense::click_and_drag()); ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); }); + // A bare interactable Area does not become the modal layer by itself. Without this, a + // Settings dialog opened before the library window can keep the modal layer and receive a + // control-channel press that the dim is meant to cover. + ctx.memory_mut(|memory| memory.set_modal_layer(dim.response.layer_id)); } -/// The blocking window (until a choice is made). -pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { +/// The blocking window (until a choice is made). `blocked` freezes the barrier of a quit prompt +/// or settings notice that was on screen when this frame began: the launch list/error window is +/// `Order::Foreground` too, so drawing it after the prompt could cover it and let the same +/// click or key act twice. It returns on the first pass after the prompt goes away. +pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { + if blocked || crate::panels::notices::blocks_input(app) { + return; + } let Some(problem) = app.library_problem.clone() else { return }; if problem.dismissed { return; } if let Some(chooser) = &problem.chooser { - // a dialog it opened (New Library's name) goes first; the list comes back after it - if app.ui.dialog.is_none() { + // its own New Library name dialog goes first; the list comes back after it. An unrelated + // dialog (Settings) does not hide the list: the blocking window stays above it. + if !chooser_owns_dialog(app) { + note_first_draw(app, WindowMode::Chooser); show_chooser(app, ctx, &problem, chooser); + app.library_window_input.drawn = Some(WindowMode::Chooser); } return; } let t = Tokens::get(ctx); + note_first_draw(app, WindowMode::Error); dim(ctx); let mut choice = None; let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); - egui::Window::new(crate::i18n::tr("Your library couldn't be opened")) - .id(egui::Id::new("library-problem")) + let shown = egui::Window::new(crate::i18n::tr("Your library couldn't be opened")) + .id(egui::Id::unique(ERROR_WINDOW_ID)) .order(egui::Order::Foreground) .collapsible(false) .resizable(false) @@ -731,7 +1047,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0))); - register(ui.ctx(), format!("button:{id}"), r.rect); + register(ui, format!("button:{id}"), r.rect); if r.clicked() { choice = Some(c); } @@ -742,6 +1058,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { button(ui, "libraryQuit", "Quit", true, Choice::Quit); }); }); + if let Some(w) = &shown { + // A press on the dim raises its area; keep this window (and its own buttons) above it. + ctx.move_to_top(w.response.layer_id); + } + app.library_window_input.drawn = Some(WindowMode::Error); if let Some(c) = choice { act(app, c); } @@ -811,8 +1132,8 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP let can_pick = app.services.pick_folder.is_some(); let open_now = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); - egui::Window::new(crate::i18n::tr("Open a Library")) - .id(egui::Id::new("library-chooser")) + let shown = egui::Window::new(crate::i18n::tr("Open a Library")) + .id(egui::Id::unique(CHOOSER_WINDOW_ID)) .order(egui::Order::Foreground) .collapsible(false) .resizable(false) @@ -826,7 +1147,7 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP ui.label(RichText::new(crate::i18n::tr("Choose the library to open.")).color(t.text_label)); if chooser.libraries.is_empty() { let r = ui.label(RichText::new(crate::i18n::tr("No recent libraries. Open one from a folder, or make a new one.")).color(t.text_dim)); - register(ui.ctx(), "indicator:noRecentLibraries", r.rect); + register(ui, "indicator:noRecentLibraries", r.rect); } egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 2.0; @@ -845,7 +1166,7 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP let selected = chooser.selected.and_then(|i| chooser.libraries.get(i)); let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0))); - register(ui.ctx(), format!("button:{id}"), r.rect); + register(ui, format!("button:{id}"), r.rect); if r.clicked() { choice = Some(c); } @@ -864,6 +1185,10 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP }); }); }); + if let Some(w) = &shown { + // A press on the dim raises its area; keep the launch list above it. + ctx.move_to_top(w.response.layer_id); + } if let Some((i, open)) = clicked_row { select(app, i); if open { @@ -913,8 +1238,8 @@ fn recent_row(ui: &mut egui::Ui, t: &Tokens, i: usize, library: &RecentLibrary, ui.add(egui::Label::new(RichText::new(text).size(11.0).color(dim)).wrap().selectable(false)); } }); - let r = ui.interact(inner.response.rect, egui::Id::new(("library-recent", i)), egui::Sense::click()); - register(ui.ctx(), format!("libraryRecent:{i}"), r.rect); + let r = ui.interact(inner.response.rect, egui::Id::unique(("library-recent", i)), egui::Sense::click()); + register(ui, format!("libraryRecent:{i}"), r.rect); let fill = if selected { Some(t.tool_active) } else if r.hovered() { @@ -945,15 +1270,21 @@ pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { .font(t.semibold(12.5)) .color(t.canvas), ); - register(ui.ctx(), "indicator:temporarySession", r.rect); + register(ui, "indicator:temporarySession", r.rect); let b = ui.add( egui::Button::new(RichText::new(crate::i18n::tr("Open Library…")).color(t.canvas)).fill(egui::Color32::from_black_alpha(40)), ); - register(ui.ctx(), "button:libraryReopen", b.rect); + register(ui, "button:libraryReopen", b.rect); reopen = b.clicked(); }); }); if reopen && let Some(p) = app.library_problem.as_mut() { p.dismissed = false; + // The banner was drawn before the window; reopening it here would otherwise settle in this + // same pass, whose background already consumed its input. Shield the next frame's first + // actual window draw. + app.library_window_input.begin_transition(); + clear_focus(ui.ctx()); + ui.ctx().request_repaint(); } } diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index ef17bfba0..86f3796b9 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; -use crate::widgets::register; +use crate::widgets::{register, register_response}; /// A slider's label in the effect lists (the colour tint's hue and saturation are one "Color"). pub fn field_label(k: &str) -> &'static str { @@ -123,7 +123,7 @@ struct MenuState { } fn state_id() -> egui::Id { - egui::Id::new("maskEffectMenuState") + egui::Id::unique("maskEffectMenuState") } /// The effects the menu lists for `query`: (section heading, effect name, its row widget id). @@ -223,7 +223,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); // (rows scrolled out of view can't be clicked) if ui.is_rect_visible(rect) { - register(ui.ctx(), widget.clone(), rect); + register(ui, widget.clone(), rect); } let lit = st.highlight == Some(i) || resp.hovered(); if lit { @@ -270,7 +270,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option ui.separator(); let item = |ui: &mut egui::Ui, id: &str, text: String, enabled: bool| { let r = ui.add_enabled(enabled, egui::Button::new(text).frame(false)); - register(ui.ctx(), id.to_string(), r.rect); + register(ui, id.to_string(), r.rect); r.clicked() }; if item(ui, "maskEffectMenu:reset", crate::i18n::tr("Reset Sliders").to_string(), true) { @@ -322,7 +322,7 @@ fn preview(app: &mut LightcraftApp, mask: &Mask, name: &str) { fn row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, name: &str, fav: bool) { let run = |app: &mut LightcraftApp, ui: &mut egui::Ui, text: &str, cmd: &str, p: Value| { let r = ui.button(crate::i18n::tr(text)); - register(ui.ctx(), format!("maskEffectRow:{text}"), r.rect); + register(ui, format!("maskEffectRow:{text}"), r.rect); if r.clicked() { if let Err(e) = app.run(cmd, p) { app.toast_error(ui.ctx(), e); @@ -398,7 +398,7 @@ pub fn save_body( .width(ui.available_width() - 30.0) .show(ui); let pick = ui.small_button("…").on_hover_text(crate::i18n::tr("Choose a group")); - register(ui.ctx(), "button:effectGroupPick", pick.rect); + register(ui, "button:effectGroupPick", pick.rect); egui::Popup::menu(&pick).show(|ui| { for (g, _) in fx::menu_order(&app.session.mask_effects) { if ui.button(if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.clone() }).clicked() { @@ -422,7 +422,7 @@ pub fn save_body( // the sliders this mask changes stand out; the others are dimmed let text = if changed.iter().any(|c| c == k) { text.color(t.text).strong() } else { text.color(t.text_dim) }; let r = cols[i % 2].checkbox(&mut on, text); - register(&r.ctx, format!("effectInclude:{k}"), r.rect); + register_response(format!("effectInclude:{k}"), r.rect, &r); if r.changed() { fields.retain(|f| f != k); if on { @@ -434,7 +434,7 @@ pub fn save_body( ui.horizontal(|ui| { for (id, text) in [("all", "All"), ("none", "None"), ("changed", "Changed Only")] { let b = ui.small_button(crate::i18n::tr(text)); - register(ui.ctx(), format!("effectInclude:{id}"), b.rect); + register(ui, format!("effectInclude:{id}"), b.rect); if b.clicked() { *fields = match id { "all" => list.iter().map(|k| k.to_string()).collect(), @@ -450,9 +450,9 @@ pub fn save_body( .size(11.0), ); let r = ui.checkbox(amount, crate::i18n::tr("Include Amount")); - register(ui.ctx(), "check:effectAmount", r.rect); + register(ui, "check:effectAmount", r.rect); let r = ui.checkbox(brush, crate::i18n::tr("Include brush size, feather and flow")); - register(ui.ctx(), "check:effectBrush", r.rect); + register(ui, "check:effectBrush", r.rect); enter } @@ -519,7 +519,7 @@ pub fn manage_body( egui::Label::new(egui::RichText::new(format!("{heading} ({})", list.len())).font(t.semibold(12.0)).color(t.text_label)) .sense(Sense::click()), ); - register(ui.ctx(), format!("effectGroup:{group}"), h.rect); + register(ui, format!("effectGroup:{group}"), h.rect); if h.clicked() { // a click on the heading selects the group *selected = list.iter().map(|e| e.name.clone()).collect(); @@ -545,7 +545,7 @@ pub fn manage_body( } let (rect, r) = ui.allocate_exact_size(vec2(240.0, 22.0), Sense::click()); if ui.is_rect_visible(rect) { - register(ui.ctx(), format!("effectRow:{}", e.name), rect); + register(ui, format!("effectRow:{}", e.name), rect); } if sel { ui.painter().rect_filled(rect, 3.0, t.tool_active); @@ -603,14 +603,14 @@ pub fn manage_body( ); ui.horizontal(|ui| { let d = ui.button(crate::i18n::tr("Delete")); - register(ui.ctx(), "button:effectDeleteConfirm", d.rect); + register(ui, "button:effectDeleteConfirm", d.rect); if d.clicked() { let _ = app.run("mask.deleteEffect", json!({"names": selected.clone()})); selected.clear(); *confirm_delete = false; } let c = ui.button(crate::i18n::tr("Cancel")); - register(ui.ctx(), "button:effectDeleteCancel", c.rect); + register(ui, "button:effectDeleteCancel", c.rect); if c.clicked() { *confirm_delete = false; } @@ -620,7 +620,7 @@ pub fn manage_body( ui.horizontal_wrapped(|ui| { let button = |ui: &mut egui::Ui, id: &str, text: &str, enabled: bool| { let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(text))); - register(ui.ctx(), format!("button:effect{id}"), r.rect); + register(ui, format!("button:effect{id}"), r.rect); r.clicked() }; if button(ui, "Apply", "Apply", one.is_some() && mask.is_some()) @@ -650,7 +650,7 @@ pub fn manage_body( let _ = app.run("mask.favoriteEffect", json!({"names": selected.clone(), "favorite": !all_fav})); } let g = ui.add_enabled(n > 0, egui::Button::new(crate::i18n::tr("Move to Group…"))); - register(ui.ctx(), "button:effectGroup", g.rect); + register(ui, "button:effectGroup", g.rect); egui::Popup::menu(&g).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let names = selected.clone(); for (grp, _) in fx::menu_order(&app.session.mask_effects) { diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 675e5da6f..ce28482c4 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -118,7 +118,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { for (i, (kind, label, icon)) in tiles.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click()); crate::widgets::announce(&resp, crate::i18n::tr(label)); - register(ui.ctx(), format!("maskNew:{kind}"), r); + register(ui, format!("maskNew:{kind}"), r); ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset }); paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label); // (cut short only in a panel too narrow for the label: the full name on hover) @@ -126,7 +126,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let g = one_line(ui.painter(), label, t.font(size), t.text_dim, tile - 2.0 * TILE_PAD); let elided = g.elided; let at = pos2(r.center().x, r.bottom() - 9.0) - g.size() / 2.0; - register(ui.ctx(), format!("maskNewLabel:{kind}"), Rect::from_min_size(at, g.size())); + register(ui, format!("maskNewLabel:{kind}"), Rect::from_min_size(at, g.size())); ui.painter().galley(at, g, t.text_dim); let resp = if elided { resp.on_hover_text(label) } else { resp }; if resp.clicked() { @@ -221,7 +221,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // inline rename: Enter (or leaving the field) commits, Escape cancels let rid = *rid; let r = ui.add(egui::TextEdit::singleline(name).desired_width(ui.available_width()).id_salt(("maskRename", rid))); - register(ui.ctx(), format!("maskRename:{rid}"), r.rect); + register(ui, format!("maskRename:{rid}"), r.rect); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -238,7 +238,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); crate::widgets::announce_selected(&resp, m.name.clone(), Some(m.id) == app.session.active_mask); - register(ui.ctx(), format!("mask:{}", m.id), r); + register(ui, format!("mask:{}", m.id), r); ui.painter().rect_filled( r, 4.0, @@ -257,15 +257,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let g = one_line(ui.painter(), &m.name, t.font(13.0), color, r.width() - 32.0 - 32.0); let elided = g.elided; let at = pos2(r.left() + 32.0, r.center().y - g.size().y / 2.0); - register(ui.ctx(), format!("maskName:{}", m.id), Rect::from_min_size(at, g.size())); + register(ui, format!("maskName:{}", m.id), Rect::from_min_size(at, g.size())); ui.painter().galley(at, g, color); let resp = if elided { resp.on_hover_text(&m.name) } else { resp }; // show / hide on hover (and always while hidden) // (the pointer test, not `hovered`: over the eye, the row itself no longer counts as hovered) if ui.rect_contains_pointer(r) || !m.visible { let er = Rect::from_center_size(pos2(r.right() - 16.0, r.center().y), vec2(22.0, 22.0)); - register(ui.ctx(), format!("maskVisible:{}", m.id), er); - let eye = ui.interact(er, egui::Id::new(("maskVisible", m.id)), Sense::click()); + register(ui, format!("maskVisible:{}", m.id), er); + let eye = ui.interact(er, egui::Id::unique(("maskVisible", m.id)), Sense::click()); crate::widgets::announce_selected(&eye, crate::i18n::tr("Show mask"), m.visible); paint( ui.painter(), @@ -323,7 +323,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if let Some((_, _, name)) = app.ui.renaming_component.as_mut().filter(|(mid, k, _)| *mid == m.id && *k == i) { // inline rename: Enter (or leaving the field) commits, Escape cancels let r = ui.add(egui::TextEdit::singleline(name).desired_width(ui.available_width() - 30.0).id_salt(("compRename", m.id, i))); - register(ui.ctx(), format!("componentRename:{i}"), r.rect); + register(ui, format!("componentRename:{i}"), r.rect); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -346,14 +346,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add(egui::Label::new(text).truncate().sense(Sense::click())) }); let resp = resp.inner; - register(ui.ctx(), format!("component:{i}"), resp.rect); + register(ui, format!("component:{i}"), resp.rect); if resp.double_clicked() { app.ui.renaming_component = Some((m.id, i, label.clone())); } crate::widgets::context_menu(&resp, |ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let more = ui.small_button("…").on_hover_text(crate::i18n::tr("Component options")); - register(ui.ctx(), format!("button:componentMenu{i}"), more.rect); + register(ui, format!("button:componentMenu{i}"), more.rect); egui::Popup::menu(&more).show(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); }); }); @@ -474,7 +474,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { for c in OVERLAY_COLORS { let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); crate::widgets::announce(&resp, format!("{} #{:02x}{:02x}{:02x}", crate::i18n::tr("Overlay color"), c[0], c[1], c[2])); - register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); + register(ui, format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2])); if app.ui.mask_overlay_color == c { ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); @@ -523,7 +523,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape let w = ui.available_width().min(240.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag()); crate::widgets::announce(&resp, crate::i18n::tr("Select Luminance Range")); - register(ui.ctx(), format!("lumRange:{comp}"), rect); + register(ui, format!("lumRange:{comp}"), rect); let p = ui.painter(); // dark → light ramp, the selected span outlined let n = 24; @@ -590,7 +590,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape }); let mut map = app.ui.mask_overlay && app.ui.mask_overlay_mode == "colorOnBw"; let r = ui.checkbox(&mut map, crate::i18n::tr("Show Luminance Map")); - register(ui.ctx(), format!("check:lumMap{comp}"), r.rect); + register(ui, format!("check:lumMap{comp}"), r.rect); if r.changed() { // the luminance map: the photo in black & white with the selected range tinted if map { @@ -810,7 +810,7 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { .hint_text(crate::i18n::tr("e.g. sky · the red car · car, road")) .desired_width(ui.available_width()), ); - register(ui.ctx(), "maskDescribe", r.rect); + register(ui, "maskDescribe", r.rect); if !r.has_focus() && !r.lost_focus() && text.is_empty() { r.request_focus(); } @@ -840,12 +840,12 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { let b = ui.button(crate::i18n::tr("Object")); - register(ui.ctx(), format!("maskComp:{op}:object"), b.rect); + register(ui, format!("maskComp:{op}:object"), b.rect); if b.clicked() { start_object(app, ui.ctx(), op); } let b = ui.button(crate::i18n::tr("Describe…")); - register(ui.ctx(), format!("maskComp:{op}:prompt"), b.rect); + register(ui, format!("maskComp:{op}:prompt"), b.rect); if b.clicked() { start_describe(app, op); } @@ -862,7 +862,7 @@ fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { continue; } let b = ui.button(menu_label(label)); - register(ui.ctx(), format!("maskComp:{op}:{kind}"), b.rect); + register(ui, format!("maskComp:{op}:{kind}"), b.rect); if b.clicked() { if kind == "brush" { let _ = app.run("tool.brush", json!({"op": op})); diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 016323e95..4dbbc1fd9 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -5,7 +5,7 @@ use egui::{Align2, RichText, vec2}; use crate::theme::Tokens; -use crate::widgets::register; +use crate::widgets::WidgetHit; use crate::{LightcraftApp, QuitPrompt}; /// Collect the library's settings-file warnings (each is shown once), and the no-GPU notice once the GPU device has @@ -82,6 +82,7 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { } } +#[derive(Clone, Copy, PartialEq, Eq)] enum Choice { Retry, QuitAnyway, @@ -89,17 +90,387 @@ enum Choice { Ok, } -fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&str, &str, Choice)]) -> Option { +/// The prompt (quit or settings notice) that owns input this frame, if any. +/// +/// The dim area is `Order::Foreground`, so a settled egui pass routes pointer input to it before +/// the main view or a `Middle` dialog. The control channel and raw keyboard/event handlers do not +/// go through that hit test, however: they must consult this explicit barrier as well. +pub(crate) fn prompt_layer(app: &LightcraftApp) -> Option { + let id = match (&app.quit_prompt, app.notices.is_empty()) { + (Some(QuitPrompt::Tasks(_)), _) => "quit-tasks", + (Some(QuitPrompt::Unsaved(_)), _) => "quit-unsaved", + (None, false) => "settings-notice", + (None, true) => return None, + }; + Some(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(id))) +} + +/// Whether a prompt is on screen and owns all pointer and keyboard input. +pub fn blocks_input(app: &LightcraftApp) -> bool { + prompt_layer(app).is_some() +} + +/// Whether a registered automation target may receive input. While a prompt is up only its own +/// Foreground window is interactive; the same barrier must hold before the first prompt layout +/// settles, when egui has not yet made the new areas interactable. +pub(crate) fn allows_target(app: &LightcraftApp, hit: Option) -> bool { + match prompt_layer(app) { + None => true, + Some(layer) => hit.is_some_and(|hit| hit.layer_id == layer), + } +} + +/// Identity of the prompt whose focus we track. A queued settings notice is a different prompt +/// from the one before it even though both use the same layer and id. +#[derive(Clone, Debug, PartialEq, Eq)] +struct PromptKey { + layer: egui::LayerId, + text: String, + queue_len: usize, +} + +/// Identity of the prompt currently owning input: its layer, text and, for the settings-notice +/// queue, the number of notices remaining, including the one shown. `None` when no prompt is up. +fn prompt_key(app: &LightcraftApp) -> Option { + let (layer, text, queue_len) = match (&app.quit_prompt, app.notices.first()) { + (Some(prompt), _) => (prompt_layer(app)?, prompt.text().to_string(), 0), + (None, Some(text)) => (prompt_layer(app)?, text.clone(), app.notices.len()), + (None, None) => return None, + }; + Some(PromptKey { layer, text, queue_len }) +} + +/// The prompt's keyboard/focus state, isolated from the still-drawn background. +/// +/// The first host frame of a new prompt is a shielding pass: pointer/touch/key/accessibility input +/// is removed before panels are drawn, and the frame is discarded so a second pass can lay out the +/// new `Area` without any input. From then on this state routes only the prompt's own keys and +/// AccessKit actions; everything else is dropped before egui or the panels can see it. +#[derive(Default)] +pub(crate) struct PromptInput { + key: Option, + /// The prompt has completed a layout pass; its `Area`s are interactable and its button ids are + /// known. Before this, input must not reach it or the panels under it. + laid_out: bool, + /// The next `pass 0` is the shielding pass for a newly shown prompt. + transition: bool, + /// Focused button index; `focus_set` distinguishes "not chosen yet" from index 0. + focus: usize, + focus_set: bool, + /// Enter/Space or an AccessKit Click on a prompt button. + activate: Option, + /// Escape (safe cancel). + cancel: bool, + /// Actual egui response ids of the prompt's buttons from its latest draw. + button_ids: Vec<(String, egui::Id)>, + /// Pointer state saved before the background draws. Restored only while the prompt itself is + /// drawn (egui's `Response::clicked` needs `pointer_events`), then hidden again for the + /// dialogs and overlays drawn after it. + pointer: Option, +} + +impl PromptInput { + /// Reset the prompt input state for `key`, discarding layout, focus, button ids and pending + /// activation. A present key arms the shield transition; `None` leaves input unowned. + fn reset_to(&mut self, key: Option) { + let transition = key.is_some(); + self.key = key; + self.laid_out = false; + self.transition = transition; + self.focus = 0; + self.focus_set = false; + self.activate = None; + self.cancel = false; + self.button_ids.clear(); + self.pointer = None; + } + + /// The index of the prompt button whose real egui id matches AccessKit's `node`, if any. + fn button_index(&self, accesskit_node: egui::accesskit::NodeId) -> Option { + self.button_ids.iter().position(|(_, id)| id.accesskit_id() == accesskit_node) + } +} + +/// Surrender egui's current keyboard focus before a chosen prompt button disappears. +fn clear_prompt_focus(ctx: &egui::Context) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); +} + +/// Whether a key press belongs to the prompt: Tab (optionally with Shift) or unmodified Enter, +/// Space or Escape. +fn prompt_key_allowed(key: egui::Key, modifiers: egui::Modifiers) -> bool { + match key { + egui::Key::Tab => modifiers.is_none() || modifiers.shift_only(), + egui::Key::Enter | egui::Key::Space | egui::Key::Escape => modifiers.is_none(), + _ => false, + } +} + +/// Whether an AccessKit action targets a prompt button on the root tree and is a Focus or Click +/// request. +fn prompt_accesskit_allowed(request: &egui::accesskit::ActionRequest, ids: &[(String, egui::Id)]) -> bool { + request.target_tree == egui::accesskit::TreeId::ROOT + && ids.iter().any(|(_, id)| id.accesskit_id() == request.target_node) + && matches!(request.action, egui::accesskit::Action::Focus | egui::accesskit::Action::Click) +} + +/// Drop host and synthetic input that the background must not receive while a prompt is up. +/// +/// Key releases and modifier changes always survive: egui updates `keys_down` from them in +/// `begin_pass`, so dropping a release would leave a key held after the prompt goes away. Pointer +/// and accessibility input is kept only once the prompt's first layout has settled. Wheel/zoom/ +/// rotate/text/clipboard input has no prompt meaning and is always removed. A touch Start/Move is +/// dropped while the prompt is open; End/Cancel always stays, +/// including the creation/transition frames, because egui's private `touch_states` tracks the +/// finger separately and dropping the release would leave it pressed after the prompt goes away. +fn filter_prompt_events(app: &LightcraftApp, raw: &mut egui::RawInput) { + raw.events.retain(|event| prompt_allows_event(app, event)); +} + +/// Whether `event` is the release half of a touch gesture. Its state lives in egui's private +/// `touch_states`, outside the `PointerState` that the prompt's cleanup resets, so it must survive +/// even before the prompt's first layout has settled. +fn touch_release(event: &egui::Event) -> bool { + matches!(event, egui::Event::Touch { phase: egui::TouchPhase::End | egui::TouchPhase::Cancel, .. }) +} + +/// Whether an input event may reach egui while a prompt is up. This is the single rule used both +/// for host input before `begin_pass` and for a synthetic batch after `raw_input_hook`; the control +/// channel can therefore reject the exact gestures that the prompt barrier would drop, while an +/// allowed prompt key, pointer action or targeted AccessKit action is still delivered. +pub(crate) fn prompt_allows_event(app: &LightcraftApp, event: &egui::Event) -> bool { + if app.notice_input.key.is_none() { + return true; + } + let laid_out = app.notice_input.laid_out; + let ids = &app.notice_input.button_ids; + match event { + // Key releases and modifier/window-focus changes always survive: egui updates `keys_down` + // from them in `begin_pass`, so dropping a release would leave a key held afterwards. + egui::Event::Key { pressed: false, .. } | egui::Event::ModifiersChanged(_) | egui::Event::WindowFocused(_) => true, + egui::Event::Key { key, pressed: true, modifiers, .. } => laid_out && prompt_key_allowed(*key, *modifiers), + egui::Event::AccessKitActionRequest(request) => laid_out && prompt_accesskit_allowed(request, ids), + egui::Event::Text(_) + | egui::Event::Ime(_) + | egui::Event::Cut + | egui::Event::Copy + | egui::Event::Paste(_) + | egui::Event::PasteImage(_) + | egui::Event::MiddleClickPaste { .. } + | egui::Event::MouseWheel { .. } + | egui::Event::Zoom(_) + | egui::Event::Rotate(_) => false, + // Pointer input at a settled prompt belongs to its backdrop or buttons. A new touch + // gesture does not, but its End/Cancel must still clear egui's private touch state even + // in the prompt's creation/transition frames. + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::PointerGone => laid_out, + touch @ egui::Event::Touch { .. } if touch_release(touch) => true, + egui::Event::Touch { .. } => false, + _ => true, + } +} + +/// Restore the pointer state saved for the prompt just before it is drawn. The background has +/// already drawn with no pointer; egui's `Response::clicked` still needs the pass's pointer events +/// to turn the interaction snapshot into a click on a prompt button. +pub(crate) fn restore_prompt_pointer(app: &LightcraftApp, ctx: &egui::Context) { + if let Some(pointer) = &app.notice_input.pointer { + ctx.input_mut(|input| input.pointer = pointer.clone()); + } +} + +/// Hide pointer state again after the prompt has drawn, so a dialog or overlay drawn later in the +/// same pass cannot act on the same release that dismissed the prompt. +pub(crate) fn hide_background_pointer(ctx: &egui::Context) { + ctx.input_mut(|input| { + input.pointer = egui::PointerState::default(); + input.smooth_scroll_delta = egui::Vec2::ZERO; + }); + ctx.stop_dragging(); +} + +/// Apply the prompt barrier after synthetic events have been appended to `raw`. +pub(crate) fn filter_raw_input(app: &LightcraftApp, raw: &mut egui::RawInput) { + filter_prompt_events(app, raw); +} + +/// Note a prompt transition before egui sees the host input. Returns true when this host frame +/// first sees a new prompt (its synthetic gestures must be canceled and its pointer state reset). +pub(crate) fn prepare_raw_input(app: &mut LightcraftApp, ctx: &egui::Context, raw: &mut egui::RawInput) -> bool { + let current = prompt_key(app); + let started = current.is_some() && app.notice_input.key != current; + if app.notice_input.key != current { + app.notice_input.reset_to(current); + clear_prompt_focus(ctx); + } + filter_prompt_events(app, raw); + started +} + +/// Apply the same barrier to the live input after `begin_pass`. A prompt can be created by this +/// frame's logic after `raw_input_hook` already ran; this captures its keys before panels read +/// them, drops the rest, and reports whether the prompt was first discovered here (so its +/// pre-existing gesture queue must be canceled). The draw pass itself uses [`shield_pass`]. +pub(crate) fn prepare_ui_pass(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + let current = prompt_key(app); + let has_prompt = current.is_some(); + let started = has_prompt && app.notice_input.key != current; + if app.notice_input.key != current { + app.notice_input.reset_to(current); + clear_prompt_focus(ctx); + } + if !has_prompt { + return false; + } + let shield = ctx.current_pass_index() == 0 && app.notice_input.transition; + let laid_out = app.notice_input.laid_out && !shield; + if laid_out { + // A screen reader's Focus/Click can target only a button whose actual response id we + // recorded in a completed prompt pass. Tab/Shift+Tab and Enter/Space/Escape are handled + // here too, so no background panel sees a key that belongs to the prompt. + let events = ctx.input(|input| input.events.clone()); + for event in &events { + match event { + egui::Event::Key { key, pressed: true, modifiers, .. } if prompt_key_allowed(*key, *modifiers) => match key { + egui::Key::Tab => { + let len = app.notice_input.button_ids.len(); + if len > 0 { + app.notice_input.focus_set = true; + app.notice_input.focus = if modifiers.shift_only() { + app.notice_input.focus.checked_add(len - 1).map_or(0, |i| i % len) + } else { + app.notice_input.focus.checked_add(1).map_or(0, |i| i % len) + }; + } + } + egui::Key::Enter | egui::Key::Space => { + app.notice_input.focus_set = true; + app.notice_input.activate = Some(app.notice_input.focus); + } + egui::Key::Escape => app.notice_input.cancel = true, + _ => {} + }, + egui::Event::AccessKitActionRequest(request) => { + if let Some(index) = app.notice_input.button_index(request.target_node) { + app.notice_input.focus_set = true; + app.notice_input.focus = index; + if request.action == egui::accesskit::Action::Click { + app.notice_input.activate = Some(index); + } + } + } + _ => {} + } + } + } + if laid_out { + app.notice_input.pointer = Some(ctx.input(|input| input.pointer.clone())); + } + ctx.input_mut(|input| { + if shield || !laid_out { + // The pass is discarded. Clear held keys too: the background must not keep a key down + // merely because its release arrived in the prompt's creation frame. + input.keys_down.clear(); + } + // The interaction snapshot was computed at begin_pass, before this reset: a prompt + // button's `Response::clicked` still reflects this pass's click. Removing the pointer from + // the live input stops panels that read `ctx.input().pointer` directly from starting a + // drag, scrolling or completing a click underneath the prompt. + input.pointer = egui::PointerState::default(); + input.smooth_scroll_delta = egui::Vec2::ZERO; + input.events.retain(|event| match event { + egui::Event::ModifiersChanged(_) | egui::Event::WindowFocused(_) => true, + egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Ime(_) => false, + egui::Event::Cut + | egui::Event::Copy + | egui::Event::Paste(_) + | egui::Event::PasteImage(_) + | egui::Event::MiddleClickPaste { .. } + | egui::Event::MouseWheel { .. } + | egui::Event::Zoom(_) + | egui::Event::Rotate(_) => false, + // The allowed Focus/Click was already dispatched above to the prompt's own button; + // dropping every AccessKit request here leaves no residual for a covered panel to act + // on in this pass (including a request that targeted a background node). + egui::Event::AccessKitActionRequest(_) => false, + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::PointerGone => false, + // A touch End/Cancel must reach egui even in this creation pass; Start/Move is + // background input like a new pointer gesture. + touch @ egui::Event::Touch { .. } if touch_release(touch) => true, + egui::Event::Touch { .. } => false, + _ => true, + }); + }); + ctx.stop_dragging(); + started +} + +/// Whether `draw_window` must render only the new prompt for this first pass. The second pass +/// (after the discard) draws the whole window with empty raw input, so the new prompt's first +/// frame cannot activate a panel underneath it. +pub(crate) fn shield_pass(app: &LightcraftApp, ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 && app.notice_input.transition && prompt_key(app).is_some() +} + +/// A completed, nondiscarded pass has laid out and focused the prompt: input may reach it from the +/// next host frame. A changed/dismissed prompt is reset here as well, so a queued next notice is +/// treated as a new transition. +pub(crate) fn finish_ui_pass(app: &mut LightcraftApp, ctx: &egui::Context) { + if ctx.will_discard() { + return; + } + let current = prompt_key(app); + if app.notice_input.key != current { + // A prompt can change during the draw itself (Tasks -> Unsaved, or OK -> next notice). + // Leave it in its transition state so the next host frame shields the new prompt too. + app.notice_input.reset_to(current); + return; + } + if current.is_some() { + app.notice_input.laid_out = true; + app.notice_input.transition = false; + } +} + +/// Draw one modal prompt and return the index of the chosen button. The dim takes the modal +/// layer and a sizing pass is discarded so the first interactive pass has settled geometry; +/// `input` keeps focus on the safe Cancel/OK button unless a keyboard or AccessKit choice arrives. +fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&str, &str, Choice)], input: &mut PromptInput) -> Option { let t = Tokens::get(ctx); let screen = ctx.content_rect(); - egui::Area::new(egui::Id::new((id, "dim"))).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| { + let last = buttons.len().checked_sub(1)?; + let cancel_index = buttons.iter().position(|(_, _, choice)| matches!(choice, Choice::Cancel | Choice::Ok)).unwrap_or(last); + if !input.focus_set { + input.focus = cancel_index; + input.focus_set = true; + } + input.focus = input.focus.min(last); + let focus = input.focus; + let manual = if std::mem::take(&mut input.cancel) { Some(cancel_index) } else { input.activate.take().filter(|index| *index <= last) }; + // The backdrop must cover modal Middle-order dialogs as well as the main view. + let dim = egui::Area::new(egui::Id::unique((id, "dim"))).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + // The first layout of a newly shown Area is a sizing pass: egui keeps it invisible and + // `interactable = false`. A release injected before the next host frame would then bypass + // the layer check and reach the widget below. Finish the real hit area in this same frame. + if ui.is_sizing_pass() { + ctx.request_discard("notice dim: settle the modal hit area before input"); + } ui.allocate_rect(screen, egui::Sense::click_and_drag()); ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140)); }); - let mut chosen = None; + // The dim (with its full-screen hit area) is the modal scope; the prompt window below is moved + // above it. Everything below the dim, including a Middle-order Settings dialog, is then + // outside egui's modal layer and loses keyboard focus registration as well as pointer hits. + ctx.memory_mut(|memory| memory.set_modal_layer(dim.response.layer_id)); + let mut chosen = manual; + let mut seen = Vec::with_capacity(buttons.len()); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); - egui::Window::new(crate::i18n::tr(title)) - .id(egui::Id::new(id)) + let shown = egui::Window::new(crate::i18n::tr(title)) + .id(egui::Id::unique(id)) .order(egui::Order::Foreground) .collapsible(false) .resizable(false) @@ -107,6 +478,10 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) .default_width(440.0) .show(ctx, |ui| { + // The prompt window has the same first-pass sizing rule as the dim above. + if ui.is_sizing_pass() { + ctx.request_discard("notice prompt: settle its buttons before input"); + } // a fixed width: a wrapped label in an auto-sized, centred window would move it every frame ui.set_width(420.0); ui.spacing_mut().item_spacing.y = 8.0; @@ -115,22 +490,45 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s ui.horizontal(|ui| { for (i, (bid, label, _)) in buttons.iter().enumerate() { let r = ui.add(egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0))); - register(ui.ctx(), format!("button:{bid}"), r.rect); - if r.clicked() { + let widget = format!("button:{bid}"); + // The real response id is the AccessKit node a screen reader targets. A hover + // probe's id would make every Focus/Click action fail to match the button. + crate::widgets::register_response(widget.clone(), r.rect, &r); + if i == focus { + r.request_focus(); + } + seen.push((widget, r.id)); + if r.clicked() && chosen.is_none() { chosen = Some(i); } } }); }); + // A backdrop press can raise its area; keep the prompt buttons above it. + if let Some(window) = shown { + ctx.move_to_top(window.response.layer_id); + } + input.button_ids = seen; + if chosen.is_some() { + // The focused prompt button is about to disappear. Drop its focus now so this pass's + // `egui_wants_keyboard_input` (and `text_focus`) does not make the next Esc look like it + // belongs to a text field/dialog still underneath. + clear_prompt_focus(ctx); + } chosen } +/// Draw the prompt owning input, if any, and act on its answer: quit-while-tasks cancels jobs +/// and re-checks for unsaved changes; quit-with-unsaved retries, quits or cancels; a settings +/// notice is removed when acknowledged. Returns without drawing when no prompt is up. pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(QuitPrompt::Tasks(text)) = app.quit_prompt.clone() { let buttons = [("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel)]; - let Some(i) = window(ctx, "quit-tasks", "Quit while tasks are running?", &text, &buttons) else { return }; + let Some(i) = window(ctx, "quit-tasks", "Quit while tasks are running?", &text, &buttons, &mut app.notice_input) else { + return; + }; app.quit_prompt = None; - if matches!(buttons[i].2, Choice::QuitAnyway) { + if buttons.get(i).is_some_and(|(_, _, choice)| *choice == Choice::QuitAnyway) { app.session.activity.cancel_all(); // unsaved changes still get their own question if may_close(app) { @@ -145,26 +543,34 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel), ]; - let Some(i) = window(ctx, "quit-unsaved", "Quit with unsaved changes?", &text, &buttons) else { return }; - match buttons[i].2 { - Choice::Retry => { + let Some(i) = window(ctx, "quit-unsaved", "Quit with unsaved changes?", &text, &buttons, &mut app.notice_input) else { + return; + }; + match buttons.get(i).map(|(_, _, choice)| *choice) { + Some(Choice::Retry) => { app.quit_prompt = None; + // A retry that fails again recreates the same notice text, so its `PromptKey` + // would otherwise look unchanged: keep the old layout, focus and button ids from + // the Retry press. Start a fresh prompt so the next frame shields it and resets + // focus to the safe Cancel/OK choice. + app.notice_input.reset_to(None); + clear_prompt_focus(ctx); if may_close(app) { app.ui.quit = true; } } - Choice::QuitAnyway => { + Some(Choice::QuitAnyway) => { app.quit_prompt = None; app.quit_confirmed = true; app.ui.quit = true; } - Choice::Cancel | Choice::Ok => app.quit_prompt = None, + Some(Choice::Cancel | Choice::Ok) | None => app.quit_prompt = None, } return; } if let Some(text) = app.notices.first().cloned() { let buttons = [("noticeOk", "OK", Choice::Ok)]; - if window(ctx, "settings-notice", "Settings file problem", &text, &buttons).is_some() { + if window(ctx, "settings-notice", "Settings file problem", &text, &buttons, &mut app.notice_input).is_some() { app.notices.remove(0); } } @@ -172,7 +578,94 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests { - use super::gpu_notice; + use super::{PromptKey, gpu_notice, prepare_ui_pass, prompt_allows_event, prompt_key}; + + /// The prompt barrier keeps its own keys and pointer input, drops text/wheel input, and drops + /// the start/move half of a new touch gesture while keeping End/Cancel to clear egui's private + /// touch state. No prompt means everything survives. + #[test] + fn a_settled_prompt_allows_its_keys_and_pointer_but_not_text_wheel_or_new_touches() { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.notice_input.key = Some(PromptKey { + layer: egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("test-prompt")), + text: "prompt".into(), + queue_len: 1, + }); + app.notice_input.laid_out = true; + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + assert!(prompt_allows_event(&app, &key(egui::Key::Tab, egui::Modifiers::NONE))); + assert!(prompt_allows_event(&app, &key(egui::Key::Escape, egui::Modifiers::NONE))); + assert!(!prompt_allows_event(&app, &key(egui::Key::A, egui::Modifiers::NONE))); + assert!(!prompt_allows_event(&app, &egui::Event::Text("x".into()))); + assert!(!prompt_allows_event( + &app, + &egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 10.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + } + )); + let touch = + |phase| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos: egui::pos2(10.0, 10.0), force: None }; + assert!(!prompt_allows_event(&app, &touch(egui::TouchPhase::Start))); + assert!(!prompt_allows_event(&app, &touch(egui::TouchPhase::Move))); + assert!(prompt_allows_event(&app, &touch(egui::TouchPhase::End))); + assert!(prompt_allows_event(&app, &touch(egui::TouchPhase::Cancel))); + assert!(prompt_allows_event(&app, &egui::Event::PointerMoved(egui::pos2(10.0, 10.0)))); + app.notice_input.key = None; + assert!(prompt_allows_event(&app, &egui::Event::Text("x".into()))); + assert!(prompt_allows_event(&app, &touch(egui::TouchPhase::Start))); + } + + /// A touch that began before a prompt must still get its End/Cancel while the prompt is in its + /// creation/transition frames (`laid_out` false). Start/Move remains background input. egui's + /// private touch state is not cleared by the pointer cleanup, so dropping the release would + /// leave the finger pressed after the prompt goes away. + #[test] + fn a_prompt_in_its_creation_frames_still_releases_touches() { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.notice_input.key = Some(PromptKey { + layer: egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("test-prompt")), + text: "prompt".into(), + queue_len: 1, + }); + app.notice_input.laid_out = false; + let touch = + |phase| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos: egui::pos2(10.0, 10.0), force: None }; + assert!(!prompt_allows_event(&app, &touch(egui::TouchPhase::Start)), "a new touch must not start on the background"); + assert!(!prompt_allows_event(&app, &touch(egui::TouchPhase::Move)), "a touch move is still background input"); + assert!(prompt_allows_event(&app, &touch(egui::TouchPhase::End)), "the release must clear egui's touch state"); + assert!(prompt_allows_event(&app, &touch(egui::TouchPhase::Cancel)), "a canceled touch must clear egui's touch state"); + app.notice_input.laid_out = true; + assert!(!prompt_allows_event(&app, &touch(egui::TouchPhase::Start)), "settling does not let a new touch reach the background"); + assert!(prompt_allows_event(&app, &touch(egui::TouchPhase::End)), "settled prompts still release existing touches"); + } + + /// `prepare_ui_pass` dispatches the prompt's own AccessKit action manually; no AccessKit + /// request may survive that pass for a covered background widget (or be dispatched twice). + #[test] + fn manual_prompt_dispatch_drops_all_accesskit_requests() { + let ctx = egui::Context::default(); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.quit_prompt = Some(crate::QuitPrompt::Tasks("tasks".into())); + app.notice_input.key = prompt_key(&app); + app.notice_input.laid_out = true; + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Click, + target_tree: egui::accesskit::TreeId::ROOT, + target_node: egui::accesskit::NodeId::from(42_u64), + data: None, + })); + let mut output = ctx.run_ui(raw, |ui| { + assert!(!prepare_ui_pass(&mut app, ui.ctx()), "the prompt was already known"); + let events = ui.ctx().input(|input| input.events.clone()); + assert!(events.is_empty(), "an AccessKit action survived manual prompt dispatch: {events:?}"); + }); + output.textures_delta.clear(); + } #[test] fn the_gpu_notice_is_only_for_a_missing_adapter() { diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index c5cd27b40..73d19cbaa 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -74,7 +74,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let r = Rect::from_min_size(pos2(title.right() + 14.0, head.center().y - 12.0), vec2(width, 24.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); let b = crate::widgets::arrow_button(&mut child, RichText::new(label).font(t.font(13.0)), crate::icons::Icon::ChevronRight, false); - register(ui.ctx(), "people:last", b.rect); + register(ui, "people:last", b.rect); if b.on_hover_text(crate::i18n::tr("Back to this person")).clicked() { let _ = app.run("view.person", json!({"name": last})); } @@ -156,9 +156,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, edge: f32, ppp: f32, selected: bool) { let t = Tokens::get(ui.ctx()); let face = Rect::from_min_size(r.min, vec2(edge, edge)); - let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("person-card", &person.name)), Sense::click()); crate::widgets::announce(&resp, person.name.clone()); - register(ui.ctx(), format!("person:{}", person.name), r); + register(ui, format!("person:{}", person.name), r); let view = app.session.face_view(person.photo, person.face); let p = ui.painter(); p.rect_filled(face, 3.0, t.canvas); @@ -187,7 +187,7 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed let full = person.name.clone(); crate::widgets::context_menu(&resp, |ui| { let r = ui.button(crate::i18n::tr("Rename Person…")); - register(ui.ctx(), "personMenu:rename", r.rect); + register(ui, "personMenu:rename", r.rect); if r.clicked() { open_rename(app, &full); } diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index f2c719d43..98b738cf2 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -129,7 +129,8 @@ pub(crate) fn handle_bulk_key(app: &mut LightcraftApp, ctx: &egui::Context) -> b .or_else(|| { page.more.iter().filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))).find(|f| { ctx.memory(|m| { - m.has_focus(egui::Id::new(("more-face", f.photo, f.index))) || m.has_focus(egui::Id::new(("more-face-above", f.photo, f.index))) + m.has_focus(egui::Id::unique(("more-face", f.photo, f.index))) + || m.has_focus(egui::Id::unique(("more-face-above", f.photo, f.index))) }) }) }); @@ -148,13 +149,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.horizontal(|ui| { ui.add_space(PAD - 4.0); let back = crate::widgets::arrow_button(ui, crate::i18n::tr("People"), crate::icons::Icon::ChevronLeft, true); - register(ui.ctx(), "person:back", back.rect); + register(ui, "person:back", back.rect); if back.clicked() { let _ = app.run("view.people", json!({})); } ui.label(RichText::new(&shown_name).font(t.semibold(15.0)).color(t.text)); let rename = ui.button(crate::i18n::tr("Rename Person…")); - register(ui.ctx(), "person:rename", rename.rect); + register(ui, "person:rename", rename.rect); if rename.clicked() { super::people::open_rename(app, &shown_name); } @@ -163,7 +164,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(PAD - 4.0); let photos = ui.button(crate::i18n::tr("Show photos")).on_hover_text(crate::i18n::tr("Their photos in the grid")); - register(ui.ctx(), "person:photos", photos.rect); + register(ui, "person:photos", photos.rect); if photos.clicked() { let _ = app.run("library.filter", json!({"person": shown_name})); let _ = app.run("view.photoGrid", json!({})); @@ -237,7 +238,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let r = Rect::from_min_size(pos2(area.left() + PAD + 330.0, area.top() + more_top + 8.0), vec2(120.0, 24.0)); let label = if step == super::faces::Setup::TurnOn { "Turn on" } else { "Open Settings" }; let b = ui.put(r, egui::Button::new(label)); - register(ui.ctx(), "faces:setup", b.rect); + register(ui, "faces:setup", b.rect); if b.clicked() { if step == super::faces::Setup::TurnOn { super::faces::take_step(app, ui.ctx(), step); @@ -291,16 +292,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit { let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; - let resp = ui.interact(r, egui::Id::new((salt, f.photo, f.index)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique((salt, f.photo, f.index)), Sense::click()); crate::widgets::announce(&resp, name.to_string()); - register(ui.ctx(), format!("{salt}:{}:{}", f.photo, f.index), r); + register(ui, format!("{salt}:{}:{}", f.photo, f.index), r); // the × is added after the tile, so it wins where they overlap let x_rect = Rect::from_min_size(pos2(r.right() - 24.0, r.top() + 4.0), vec2(20.0, 20.0)); - let x_resp = more.then(|| ui.interact(x_rect, egui::Id::new(("more-face-x", f.photo, f.index)), Sense::click())); + let x_resp = more.then(|| ui.interact(x_rect, egui::Id::unique(("more-face-x", f.photo, f.index)), Sense::click())); let above_rect = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(24.0, 20.0)); - let above_resp = more.then(|| ui.interact(above_rect, egui::Id::new(("more-face-above", f.photo, f.index)), Sense::click())); + let above_resp = more.then(|| ui.interact(above_rect, egui::Id::unique(("more-face-above", f.photo, f.index)), Sense::click())); if more { - register(ui.ctx(), format!("more-face-above:{}:{}", f.photo, f.index), above_rect); + register(ui, format!("more-face-above:{}:{}", f.photo, f.index), above_rect); } let hovered = resp.hovered() || x_resp.as_ref().is_some_and(|x| x.hovered()) || above_resp.as_ref().is_some_and(|x| x.hovered()); let p = ui.painter(); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index b4b753d68..6d35a5027 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -62,7 +62,7 @@ impl AppliedByPhoto { /// Remember `preset` on the photos `preset.apply` just changed (the selection, else the active /// photo; an active photo outside the selection wasn't changed and isn't marked). fn remember(ctx: &egui::Context, session: &lightcraft_engine::Session, preset: &str) { - let id = egui::Id::new(Self::ID); + let id = egui::Id::unique(Self::ID); let mut map: Self = ctx.data(|d| d.get_temp(id)).unwrap_or_default(); for photo in session.selected() { if let Some(d) = session.develop_of(photo) { @@ -75,7 +75,7 @@ impl AppliedByPhoto { /// The preset to mark for `photo`: the one it was given, while its settings are unchanged since. fn of(ctx: &egui::Context, session: &lightcraft_engine::Session, photo: Option) -> Option { let photo = photo?; - let map: Self = ctx.data(|d| d.get_temp(egui::Id::new(Self::ID)))?; + let map: Self = ctx.data(|d| d.get_temp(egui::Id::unique(Self::ID)))?; let (preset, then) = map.0.get(&photo)?; let now = session.develop_of(photo)?; (Arc::ptr_eq(then, &now) || **then == *now).then(|| preset.clone()) @@ -111,7 +111,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, crate::i18n::tr("More preset options")); egui::Popup::menu(&more).show(|ui| { let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails")); - register(ui.ctx(), "presetMenu:thumbnails", r.rect); + register(ui, "presetMenu:thumbnails", r.rect); ui.separator(); if ui.button(crate::i18n::tr("Import Presets…")).clicked() { let _ = app.run("file.importPresets", json!({})); @@ -123,7 +123,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); divider(ui); // amount slider (applies to the last applied preset) - let amt_id = egui::Id::new("preset-amount"); + let amt_id = egui::Id::unique("preset-amount"); let last: Option = ui.data(|d| d.get_temp(amt_id)); if let Some(last) = last.clone().filter(|last| last.is_current(&app.session)) { let spec = ControlSpec { @@ -170,12 +170,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { for grp in groups.iter() { let (g, builtin, favorites) = (grp.name.clone(), grp.builtin, grp.favorites); // the Favorites section opens and closes apart from a user group named "Favorites" - let open_id = egui::Id::new(("preset-group", favorites, &g)); + let open_id = egui::Id::unique(("preset-group", favorites, &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); crate::widgets::announce(&resp, g.to_string()); // outside the `presetGroup:` ids, which a user group of any name can take - register(ui.ctx(), if favorites { "presetFavorites".to_string() } else { format!("presetGroup:{g}") }, r); + register(ui, if favorites { "presetFavorites".to_string() } else { format!("presetGroup:{g}") }, r); paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), @@ -212,10 +212,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); crate::widgets::announce(&resp, name.clone()); // a starred preset is listed twice: in Favorites and in its group - register(ui.ctx(), if favorites { format!("presetFavorite:{pid}") } else { format!("preset:{pid}") }, r); + register(ui, if favorites { format!("presetFavorite:{pid}") } else { format!("preset:{pid}") }, r); let is_last = marked.as_deref() == Some(pid.as_str()); if is_last { - register(ui.ctx(), "presetApplied", r); + register(ui, "presetApplied", r); ui.painter().rect_filled(r, 0.0, t.tool_active); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7)); @@ -248,13 +248,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let label = crate::i18n::tr_format!("Preset: {name}", name = name); app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s }); if let Some(id) = active { - ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label))); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("last-preset-hover"), (id, pr.clone(), label))); } } if resp.clicked() && app.run("preset.apply", json!({"id": pid, "amount": 100})).is_ok() { AppliedByPhoto::remember(ui.ctx(), &app.session, &pid); ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, pid.clone(), 100.0))); - ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); + ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::unique("last-preset-hover"))); app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); } let (builtin, group) = (pr.builtin, pr.group.clone()); @@ -308,7 +308,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.add_space(30.0); }); - let last_hover_id = egui::Id::new("last-preset-hover"); + let last_hover_id = egui::Id::unique("last-preset-hover"); let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos)); if pointer_in_scroll { if app.hover_preview.is_none() @@ -341,7 +341,7 @@ struct Group { /// The presets by group (groups sorted by name, presets in list order), rebuilt only when the /// list changed (its generation). fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc> { - let id = egui::Id::new("presets-grouped"); + let id = egui::Id::unique("presets-grouped"); if let Some((generation, groups)) = ctx.data(|d| d.get_temp::<(u64, Arc>)>(id)) && generation == presets.generation() { @@ -372,7 +372,7 @@ fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc> { /// wherever the group changes. Only the rows in view are built (10 000+ presets made an open /// picker take seconds a frame). Returns the id of the preset clicked. pub fn picker_rows(ui: &mut egui::Ui, salt: &str, presets: &PresetList, selected: Option<&str>) -> Option { - let id = egui::Id::new("preset-picker-rows"); + let id = egui::Id::unique("preset-picker-rows"); let rows = match ui.ctx().data(|d| d.get_temp::<(u64, Arc>)>(id)) { Some((generation, rows)) if generation == presets.generation() => rows, _ => { diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 000ebdbcb..cf9a6c50c 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -72,11 +72,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } for (title, tag, imported, list) in sections { // an imported folder named like a built-in group still opens and closes on its own - let open_id = egui::Id::new(("profile-group", imported, title)); + let open_id = egui::Id::unique(("profile-group", imported, title)); let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); crate::widgets::announce(&resp, title.to_string()); - register(ui.ctx(), if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r); + register(ui, if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r); paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), @@ -110,7 +110,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(6.0); } ui.add_space(30.0); - let last_hover_id = egui::Id::new("last-profile-hover"); + let last_hover_id = egui::Id::unique("last-profile-hover"); let pointer_in_profiles = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| ui.max_rect().contains(pos)); if pointer_in_profiles { if app.hover_preview.is_none() @@ -153,9 +153,9 @@ fn cell_ui( let (p_id, p_name, p_group) = (p.0.as_str(), p.1.as_str(), p.2.as_str()); let t = Tokens::get(ui.ctx()); let key = if tag.is_empty() { format!("profileCell:{p_id}") } else { format!("profileCell:{tag}:{p_id}") }; - let resp = ui.interact(cell, egui::Id::new(&key), Sense::click()); + let resp = ui.interact(cell, egui::Id::unique(&key), Sense::click()); crate::widgets::announce(&resp, p_name.to_string()); - register(ui.ctx(), key, cell); + register(ui, key, cell); let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th)); let s = with_profile_id(d, p_id); let painter = ui.painter(); @@ -178,7 +178,7 @@ fn cell_ui( // favourite star (always shown when set, on hover otherwise) let fav = app.session.profile_favorites.iter().any(|f| f == p_id); let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); - register(ui.ctx(), format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p_id), star); + register(ui, format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p_id), star); let over_star = resp.hover_pos().is_some_and(|q| star.contains(q)); if fav || resp.hovered() { painter.circle_filled(star.center(), 10.0, Color32::from_black_alpha(110)); @@ -187,10 +187,10 @@ fn cell_ui( if resp.hovered() && !applied && !over_star { app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(p_id, p_name)), settings: s.clone() }); - ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string()))); + ui.data_mut(|m| m.insert_temp(egui::Id::unique("last-profile-hover"), (id, p_id.to_string()))); } else if resp.hovered() && applied { // resting on the applied profile shows it (no preview), not the last other profile hovered - ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover"))); + ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::unique("last-profile-hover"))); } let resp = resp.on_hover_text(format!( "{} ({})", @@ -202,7 +202,7 @@ fn cell_ui( let _ = app.run("profile.favorite", json!({"id": p_id})); } else if !applied { let _ = app.run("develop.profile", json!({"id": p_id, "amount": 100})); - ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover"))); + ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::unique("last-profile-hover"))); } } } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index a29534be3..ec178223d 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -11,6 +11,23 @@ use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::{divider, register, slider, text_button}; +mod crop; +mod history; +mod info; +mod keywords; + +#[cfg(test)] +use crop::aspect_label; +pub(crate) use crop::tool_overlay_options; +use crop::{crop, red_eye, remove}; +#[cfg(test)] +use history::{HistoryRow, history_row, short_time}; +use history::{activity, versions}; +use info::info; +#[cfg(test)] +pub(crate) use info::mixed_meta; +use keywords::keywords; + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); @@ -20,6 +37,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { + // (without a selection there is no Add keyword box for Add Keywords to put the cursor in) + app.ui.focus_keywords = None; egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; if fold_header(app, ui, "keywording", "Keywords") { @@ -45,13 +64,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { area = area.vertical_scroll_offset(offset); } // (where the pass starts: by its end egui may have pulled a shrunken panel's offset back) - let area_id = ui.data(|d| d.get_temp::(egui::Id::new(AREA))); - let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.offset.y)); + let area_id = ui.data(|d| d.get_temp::(egui::Id::unique(AREA))); + let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.clamped_offset().y)); ui.data_mut(|d| match start { Some(o) => { - d.insert_temp(egui::Id::new(OFFSET), o); + d.insert_temp(egui::Id::unique(OFFSET), o); } - None => d.remove::(egui::Id::new(OFFSET)), + None => d.remove::(egui::Id::unique(OFFSET)), }); let scrolled = area.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; @@ -69,7 +88,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::None => {} } }); - ui.data_mut(|d| d.insert_temp(egui::Id::new(AREA), scrolled.id)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(AREA), scrolled.id)); }); } @@ -83,11 +102,11 @@ const HOLD: &str = "right-scroll-hold"; /// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list /// in place (a scroll asked for during a pass only lands a frame later). fn held_offset(ctx: &egui::Context) -> Option { - let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::new(HOLD)))?; + let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::unique(HOLD)))?; if asked.checked_add(1) != Some(ctx.cumulative_pass_nr()) { return None; } - let offset = ctx.data(|d| d.get_temp::(egui::Id::new(OFFSET)))?; + let offset = ctx.data(|d| d.get_temp::(egui::Id::unique(OFFSET)))?; Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) } @@ -109,8 +128,8 @@ pub fn fold_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: pub fn fold_title(app: &mut LightcraftApp, ui: &egui::Ui, id: &str, r: Rect, resp: &egui::Response, title: &str) -> bool { let t = Tokens::get(ui.ctx()); let open = !app.ui.section_folded(id); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); - register(ui.ctx(), format!("section:{id}"), r); + crate::widgets::announce_disclosure(resp, true, open, title); + register(ui, format!("section:{id}"), r); let p = ui.painter(); if resp.hovered() { p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06)); @@ -138,1339 +157,5 @@ fn padded(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 6 }).show(ui, add); } -/// The aspect presets of the Crop panel: (menu label, `crop.aspect` value). -const ASPECT_PRESETS: [(&str, &str); 10] = [ - ("Free", "free"), - ("Original", "original"), - ("1 × 1", "1x1"), - ("4 × 5 / 8 × 10", "4x5"), - ("8.5 × 11", "8.5x11"), - ("5 × 7", "5x7"), - ("2 × 3 / 4 × 6", "2x3"), - ("4 × 3", "4x3"), - ("16 × 9", "16x9"), - ("16 × 10", "16x10"), -]; - -/// What the aspect button says for the stored lock (`aspect` = width × 100, height × 100): -/// a preset's name when the ratio matches one in either orientation, "Original" for the photo's own -/// shape, otherwise the ratio as `1.37 : 1`. -fn aspect_label(aspect: Option<(u32, u32)>, original: Option) -> String { - let Some((w, h)) = aspect else { return "Free".into() }; - if w == 0 || h == 0 { - return "Free".into(); - } - let r = w as f64 / h as f64; - let is = |q: f64| q.is_finite() && q > 0.0 && ((r - q).abs() / q < 0.004 || (1.0 / r - q).abs() / q < 0.004); - if original.is_some_and(is) { - return "Original".into(); - } - for (label, key) in ASPECT_PRESETS { - if let Some((x, y)) = key.split_once('x') - && let (Ok(x), Ok(y)) = (x.parse::(), y.parse::()) - && is(x / y) - { - return label.to_string(); - } - } - format!("{:.2} : 1", r.max(1.0 / r)) -} - -/// "Custom" row of the aspect menu: two number fields and an Apply button (`crop.aspect` `[w, h]`). -fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let key = egui::Id::new("crop-custom-aspect"); - let (mut w, mut h): (String, String) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_else(|| ("3".into(), "2".into())); - let mut apply = false; - ui.horizontal(|ui| { - ui.label(crate::i18n::tr("Custom")); - let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0)); - crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width")); - register(ui.ctx(), "field:cropCustomW", a.rect); - ui.label("×"); - let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0)); - crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height")); - register(ui.ctx(), "field:cropCustomH", b.rect); - let go = text_button(ui, "cropCustomApply", "Apply", false); - apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter))); - }); - if apply { - // Accept `3`, `2.5` and `3,5`; anything else (zero, negative, text, huge) is ignored. - let num = |t: &str| t.trim().replace(',', ".").parse::().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 1000.0); - if let (Some(x), Some(y)) = (num(&w), num(&h)) - && (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&(x / y)) - { - let _ = app.run("crop.aspect", json!({"aspect": [x, y]})); - ui.close(); - } - } - ui.data_mut(|d| d.insert_temp(key, (w, h))); -} - -fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let d = app.session.develop_of(id).unwrap_or_default(); - header(ui, "Crop"); - padded(ui, |ui| { - let original = app.session.catalog.photo(id).map(|p| { - let (w, h) = (p.width.max(1) as f64, p.height.max(1) as f64); - if d.orientation.swaps_axes() { h / w } else { w / h } - }); - ui.horizontal(|ui| { - ui.label(crate::i18n::tr("Aspect Ratio")); - let cur = aspect_label(app.crop_tool_aspect(), original); - let t = Tokens::get(ui.ctx()); - let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text); - - egui::Popup::menu(&r).show(|ui| { - for (label, a) in ASPECT_PRESETS { - if ui.button(crate::i18n::tr(label)).clicked() { - let _ = app.run("crop.aspect", json!({"aspect": a})); - } - } - ui.separator(); - custom_aspect(app, ui); - }); - let locked = app.crop_tool_aspect().is_some(); - if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() { - let _ = app.run("crop.aspect", json!({"aspect": "toggle"})); - } - }); - ui.add_space(6.0); - ui.horizontal_wrapped(|ui| { - if text_button(ui, "cropRotateLeft", crate::i18n::tr("Rotate Left"), false).clicked() { - let _ = app.run("photo.rotateLeft", json!({})); - } - if text_button(ui, "cropRotateRight", crate::i18n::tr("Rotate Right"), false).clicked() { - let _ = app.run("photo.rotateRight", json!({})); - } - }); - ui.add_space(6.0); - ui.horizontal_wrapped(|ui| { - if text_button(ui, "cropFlipH", crate::i18n::tr("Flip H"), d.crop.flip_h).clicked() { - let _ = app.run("photo.flipHorizontal", json!({})); - } - if text_button(ui, "cropFlipV", crate::i18n::tr("Flip V"), d.crop.flip_v).clicked() { - let _ = app.run("photo.flipVertical", json!({})); - } - if text_button(ui, "cropSwapAspect", crate::i18n::tr("Swap Aspect (X)"), false).clicked() { - let _ = app.run("crop.rotateAspect", json!({})); - } - if text_button(ui, "cropReset", crate::i18n::tr("Reset"), false).clicked() { - let _ = app.run("crop.reset", json!({})); - } - }); - }); - let ang = lightcraft_develop::controls::find("crop.angle").copied(); - if let Some(spec) = ang { - let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten")); - super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v}))); - // an exact angle, typed (issue #534): a field of its own, besides the slider's value - padded(ui, |ui| { - ui.horizontal(|ui| { - ui.label(crate::i18n::tr("Angle")); - let mut angle = d.crop.geometry.angle; - let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); - let r = ui.add( - egui::DragValue::new(&mut angle) - .range(spec.min..=spec.max) - .clamp_existing_to_range(false) - .speed(0.05) - .fixed_decimals(2) - // as the readout writes it: "+2.50", and every zero "0.00" - .custom_formatter(|v, _| super::detail::crop_angle_label(v).trim_end_matches('°').to_string()) - .suffix("°") - // read as the Straighten value reads a typed one: "-3,25" too - .custom_parser(|text| crate::widgets::typed_value(&spec, text.trim().trim_end_matches('°'))) - // a typed angle applies on Return (or leaving the field), not keystroke by - // keystroke: no half-typed angles, one undo step - .update_while_editing(false), - ); - crate::widgets::register(ui.ctx(), "cropAngleField", r.rect); - // a drag on the field is one undo step, like the slider's - if r.drag_started() { - let _ = app.run("develop.beginInteraction", json!({"label": "Straighten"})); - } - // egui reports a change while text is typed even when the value isn't updated yet: - // apply only a new angle - // Esc keeps the old angle, but egui applies the typed text on the frame after the - // one where Esc took the focus away: remember the Esc for that frame - let cancel = egui::Id::new("cropAngleFieldEscaped"); - let escaped = ui.data_mut(|m| m.remove_temp::(cancel)).unwrap_or(false); - if escape && r.lost_focus() { - ui.data_mut(|m| m.insert_temp(cancel, true)); - } - if r.changed() && !escaped && angle.is_finite() && angle != d.crop.geometry.angle { - let _ = app.run("crop.straighten", json!({"angle": (angle.clamp(spec.min, spec.max) * 100.0).round() / 100.0})); - } - if r.drag_stopped() { - let _ = app.run("develop.endInteraction", json!({})); - } - }); - }); - } - padded(ui, |ui| { - ui.horizontal(|ui| { - let on = app.ui.tool == "straighten"; - if text_button(ui, "straightenTool", crate::i18n::tr("Straighten Tool"), on) - .on_hover_text(crate::i18n::tr("Drag along the horizon; double-click for Auto")) - .clicked() - { - app.ui.tool = if on { String::new() } else { "straighten".into() }; - } - if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() { - app.auto_straighten(ui.ctx()); - } - }); - }); - padded(ui, |ui| { - ui.label(crate::i18n::tr("Overlay")); - { - use crate::state::CropOverlay as O; - let opts = [ - (O::Thirds, "Thirds", "thirds"), - (O::Grid, "Grid", "grid"), - (O::Golden, "Golden", "golden"), - (O::Diagonal, "Diagonal", "diagonal"), - (O::Triangle, "Triangle", "triangle"), - (O::Spiral, "Spiral", "spiral"), - (O::None, "None", "none"), - ]; - let items: Vec<(&str, &str)> = opts.iter().map(|(_, l, k)| (*l, *k)).collect(); - let active = opts.iter().position(|(o, _, _)| *o == app.ui.crop_overlay); - if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) { - app.ui.crop_overlay = opts[i].0; - } - if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) - && text_button(ui, "cropOverlayOrient", crate::i18n::tr("Flip Overlay (⇧O)"), false).clicked() - { - let _ = app.run("view.cropOverlayOrientation", json!({})); - } - } - }); - divider(ui); - header(ui, "Geometry"); - padded(ui, |ui| { - ui.label(crate::i18n::tr("Upright")); - { - use lightcraft_develop::Upright; - let modes = [ - ("Off", Upright::Off, "off"), - ("Auto", Upright::Auto, "auto"), - ("Guided", Upright::Guided, "guided"), - ("Level", Upright::Level, "level"), - ("Vertical", Upright::Vertical, "vertical"), - ("Full", Upright::Full, "full"), - ]; - let items: Vec<(&str, &str)> = modes.iter().map(|(l, _, k)| (*l, *k)).collect(); - let active = modes.iter().position(|(_, m, _)| *m == d.geometry.upright); - if let Some(i) = crate::widgets::segmented(ui, "upright", &items, active, 3) { - let (_, mode, key) = modes[i]; - let _ = app.run("geometry.upright", json!({"mode": key})); - app.ui.tool = if mode == Upright::Guided { "guidedUpright".into() } else { String::new() }; - } - } - let guided = d.geometry.upright == lightcraft_develop::Upright::Guided; - ui.horizontal_wrapped(|ui| { - if !guided - && d.geometry.upright != lightcraft_develop::Upright::Off - && text_button(ui, "uprightUpdate", crate::i18n::tr("Update"), false).clicked() - { - let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default(); - let _ = app.run("geometry.upright", json!({"mode": mode})); - } - if guided { - let drawing = app.ui.tool == "guidedUpright"; - if text_button(ui, "uprightDraw", crate::i18n::tr("Draw Guides"), drawing).clicked() { - app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() }; - } - if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", crate::i18n::tr("Clear Guides"), false).clicked() { - let _ = app.run("geometry.guides", json!({"guides": []})); - } - } - }); - if guided { - ui.label( - egui::RichText::new(crate::i18n::tr_format!( - "{} of 4 guides — drag along lines that should be vertical or horizontal.", - d.geometry.guides.len() - )) - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), - ); - } - ui.add_space(4.0); - let mut c = d.geometry.constrain_crop; - if ui.checkbox(&mut c, crate::i18n::tr("Constrain Crop")).changed() { - let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"})); - } - }); - for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") { - let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default); - let out = slider(ui, spec, v, true, None); - super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v}))); - } -} - -fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let d = app.session.develop_of(id).unwrap_or_default(); - header(ui, "Remove"); - padded(ui, |ui| { - ui.horizontal_wrapped(|ui| { - for (label, tool) in [("Remove", "remove"), ("Content-Aware", "fill"), ("Heal", "heal"), ("Clone", "clone")] { - if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() { - app.ui.tool = tool.into(); - } - } - }); - ui.add_space(8.0); - ui.label(crate::i18n::tr_format!("{} spot(s) on this photo", d.spots.len())); - ui.add_space(4.0); - if text_button(ui, "findDust", crate::i18n::tr("Find Dust Spots"), false) - .on_hover_text(crate::i18n::tr("Heal the small, soft dark spots sensor dust leaves on smooth areas")) - .clicked() - { - match app.run("spot.findDust", json!({})) { - Ok(r) => { - let n = r["added"].as_u64().unwrap_or(0); - app.toast( - ui.ctx(), - if n == 0 { - crate::i18n::tr("No dust spots found").to_string() - } else { - crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n) - }, - ); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - ui.add_space(4.0); - ui.label(egui::RichText::new(crate::i18n::tr("Paint over a distraction on the photo to remove it.")).color(Tokens::get(ui.ctx()).text_dim)); - }); - // brush settings; with a spot selected they edit that spot too - let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone()))); - if let Some((i, sp)) = &sel { - (app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32); - divider(ui); - let mode = match sp.mode { - lightcraft_develop::SpotMode::Heal => "Heal", - lightcraft_develop::SpotMode::Clone => "Clone", - lightcraft_develop::SpotMode::Remove => "Remove", - lightcraft_develop::SpotMode::Fill => "Content-Aware", - }; - super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = crate::i18n::tr(mode))); - } - let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { - id, - label, - section: lightcraft_develop::Section::Detail, - min, - max, - default, - step: 1.0, - decimals: 0, - track: lightcraft_develop::Track::Plain, - }; - let sliders = [ - (plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64), - (plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64), - (plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64), - ]; - for (spec, key, v) in sliders { - let out = slider(ui, &spec, v, true, None); - let scale = if key == "size" { 1000.0 } else { 1.0 }; - if let Some(v) = out.value { - match key { - "size" => app.ui.remove_size = (v / scale) as f32, - "feather" => app.ui.remove_feather = v as f32, - _ => app.ui.remove_opacity = v as f32, - } - } - if sel.is_some() { - super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale}))); - } - } - if sel.is_some() { - padded(ui, |ui| { - ui.horizontal(|ui| { - if text_button(ui, "spotRefresh", crate::i18n::tr("Refresh Source (/)"), false).clicked() { - let _ = app.run("spot.refreshSource", json!({})); - } - if text_button(ui, "spotDelete", crate::i18n::tr("Delete (⌫)"), false).clicked() { - let _ = app.run("spot.delete", json!({})); - } - }); - }); - } - // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out - padded(ui, |ui| { - let mut v = app.ui.visualize_spots; - if ui.checkbox(&mut v, crate::i18n::tr("Visualize Spots (A)")).changed() { - app.ui.visualize_spots = v; - } - }); - let spec = lightcraft_develop::ControlSpec { - id: "ui.spotsThreshold", - label: "Threshold", - section: lightcraft_develop::Section::Detail, - min: 0.0, - max: 100.0, - default: 50.0, - step: 1.0, - decimals: 0, - track: lightcraft_develop::Track::Plain, - }; - let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None); - if let Some(v) = out.value { - app.ui.spots_threshold = v as f32; - } - padded(ui, |ui| { - tool_overlay_options(app, ui); - ui.add_space(4.0); - if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() { - for i in (0..d.spots.len()).rev() { - let _ = app.run("spot.delete", json!({"index": i})); - } - } - }); -} - -/// Tool Overlay: when the spot and mask pins show on the photo (Auto / Always / Selected / Never; -/// Shift+H steps through them). Remove and Masking panels. -pub(crate) fn tool_overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { - use crate::state::ToolOverlay; - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Tool Overlay")).color(t.text_dim)); - let current = app.ui.tool_overlay; - let r = crate::widgets::dropdown(ui, "toolOverlay", crate::i18n::tr(current.label()), t.font(12.5), t.text_label) - .on_hover_text(crate::i18n::tr("When pins show on the photo (Shift+H cycles)")); - egui::Popup::menu(&r).show(|ui| { - for m in ToolOverlay::ALL { - let item = ui.selectable_label(m == current, crate::i18n::tr(m.label())); - register(ui.ctx(), format!("toolOverlay:{}", m.name()), item.rect); - if item.clicked() { - let _ = app.run("view.toolOverlay", json!({"mode": m.name()})); - } - } - }); - }); -} - -fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let d = app.session.develop_of(id).unwrap_or_default(); - header(ui, "Red Eye"); - padded(ui, |ui| { - ui.horizontal_wrapped(|ui| { - for (label, pet) in [("Red Eye", false), ("Pet Eye", true)] { - if text_button(ui, &format!("eyeMode-{}", if pet { "pet" } else { "red" }), label, app.ui.eye_pet == pet).clicked() { - app.ui.eye_pet = pet; - } - } - }); - ui.add_space(8.0); - ui.label( - egui::RichText::new(crate::i18n::tr("Drag over an eye on the photo; the pupil inside is found automatically.")) - .color(Tokens::get(ui.ctx()).text_dim), - ); - }); - let n = d.red_eye.len(); - if n == 0 { - return; - } - if app.ui.eye >= n { - app.ui.eye = n - 1; - } - let i = app.ui.eye; - let eye = d.red_eye[i]; - divider(ui); - super::edit::sub_title( - ui, - &crate::i18n::tr_format!( - "{label} {index} of {n}", - label = crate::i18n::tr(if eye.pet { "Pet Eye" } else { "Red Eye" }), - index = i + 1, - n = n - ), - ); - for k in ["pupilSize", "darken"] { - let cid = format!("redEye.{i}.{k}"); - let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue }; - let v = lightcraft_develop::controls::get(&d, &cid).unwrap_or(spec.default); - let out = slider(ui, spec, v, true, None); - super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": cid, "value": v}))); - } - padded(ui, |ui| { - if eye.pet { - let mut on = eye.catchlight.is_some(); - if ui.checkbox(&mut on, crate::i18n::tr("Add Catchlight")).changed() { - let _ = app.run("redeye.catchlight", json!({"index": i, "on": on})); - } - ui.add_space(4.0); - } - ui.horizontal(|ui| { - if text_button(ui, "eyeDelete", crate::i18n::tr("Delete"), false).clicked() { - let _ = app.run("redeye.delete", json!({"index": i})); - } - if n > 1 && text_button(ui, "eyeDeleteAll", crate::i18n::tr("Delete all"), false).clicked() { - for k in (0..n).rev() { - let _ = app.run("redeye.delete", json!({"index": k})); - } - } - }); - }); -} - -fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let Some(p) = app.session.catalog.photo(id).cloned() else { return }; - header(ui, "Info"); - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - camera_card(ui, &p); - ui.add_space(6.0); - if let Some(why) = &p.preview_only { - if crate::widgets::preview_only_notice(ui, "info", why) { - crate::panels::edit::reload_preview_only(app, ui.ctx(), id); - } - ui.add_space(6.0); - } - if let Some(name) = &p.copy_name { - let of = p - .copy_of - .and_then(|m| app.session.catalog.photo(m)) - .map(|m| m.file_name.clone()) - .unwrap_or_else(|| crate::i18n::tr("a removed photo").into()); - ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label)); - } - ui.add_space(8.0); - if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner { - let mut params = json!({"rating": r}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.rate", params); - } - ui.add_space(6.0); - // colour label: one swatch per label (hover shows its name); clicking the current one clears it - ui.horizontal(|ui| { - for l in lightcraft_catalog::ColorLabel::ALL { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}"))); - let key = format!("{l:?}").to_lowercase(); - register(ui.ctx(), format!("label:{key}"), r); - let on = p.label == Some(l); - ui.painter().circle_filled(r.center(), if on || resp.hovered() { 8.0 } else { 6.5 }, crate::panels::grid::label_color(l)); - if on { - ui.painter().circle_stroke(r.center(), 10.0, egui::Stroke::new(1.5, t.text)); - } - let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l)); - if resp.clicked() { - // in Compare / Survey only the active photo, as the rating keys and the bottom pill do - let mut params = json!({"label": if on { "none".to_string() } else { key }}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.label", params); - } - } - if let Some(l) = p.label { - ui.label(egui::RichText::new(app.session.catalog.label_name(l)).color(t.text_label)); - } - }); - }); - divider(ui); - let m = p.meta.clone(); - padded(ui, |ui| { - for (label, key, value, lines) in [ - ("Title", "title", &m.title, 1), - ("Caption", "caption", &m.caption, 2), - ("Alt Text", "altText", &m.alt_text, 2), - ("Extended Description", "extendedDescription", &m.extended_description, 3), - ("Copyright", "copyright", &m.copyright, 1), - ] { - meta_field(app, ui, label, key, value, lines); - } - copyright_status(app, ui, m.copyright_status); - for (label, key, value, lines) in [ - ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), - ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), - ("Creator", "creator", &m.creator, 1), - ] { - meta_field(app, ui, label, key, value, lines); - } - // file: name (rename), path (reveal), capture time (edit) - let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim)); - small(ui, "File Name"); - ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() { - let _ = app.run("dialog.rename", json!({})); - } - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.add(egui::Label::new(egui::RichText::new(&p.file_name).color(t.text)).truncate()); - }); - }); - ui.add_space(4.0); - small(ui, "File Path"); - ui.horizontal(|ui| { - let path = match &p.source { - lightcraft_catalog::Source::File { path } => path.clone(), - lightcraft_catalog::Source::Demo { .. } => crate::i18n::tr("Generated demo photo").into(), - }; - ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate()); - if crate::menus::ui_enabled(app, "app.showInFinder") { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, crate::menus::reveal_label()) - .clicked() - { - let _ = app.run("app.showInFinder", json!({})); - } - }); - } - }); - ui.add_space(4.0); - small(ui, "Captured"); - ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() { - let _ = app.run("dialog.captureTime", json!({})); - } - let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into()); - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate()); - }); - }); - ui.add_space(6.0); - for (label, key, value) in [ - ("Location", "location", &m.location), - ("City", "city", &m.city), - ("State / Province", "state", &m.state), - ("Country", "country", &m.country), - ] { - meta_field(app, ui, label, key, value, 1); - } - // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it - let gps = m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default(); - let gid = egui::Id::new("info-gps"); - ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); - let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY)); - crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); - register(ui.ctx(), "field:gps", r.rect); - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(gid, text.clone())); - } else { - ui.data_mut(|d| d.remove::(gid)); - } - if r.lost_focus() - && text.trim() != gps - && let Err(e) = app.run("photo.setMeta", json!({"gps": text.trim()})) - { - app.toast(ui.ctx(), e); - } - if let Some((la, lo)) = m.gps { - let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); - if text_button(ui, "showOnMap", crate::i18n::tr("Show on Map"), false) - .on_hover_text(crate::i18n::tr("Open the place in OpenStreetMap")) - .clicked() - { - let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}"); - if let Err(e) = crate::links::open(app, &url) { - app.toast(ui.ctx(), e); - } - } - }); - if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) - .on_hover_text(format!( - "{}\n{}", - crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)"), - crate::i18n::tr("The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.") - )) - .clicked() - && let Err(e) = app.run("metadata.reverseGeocode", json!({})) - { - app.toast_error(ui.ctx(), e); - } - } - if let Some(a) = p.analysis { - let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness); - if a.clipped > 0.02 { - line.push_str(&crate::i18n::tr_format!(" · {:.0}% clipped", a.clipped * 100.0)); - } - if a.group.is_some() { - line.push_str(if a.best { " · best of its burst" } else { " · in a burst" }); - } - ui.add_space(4.0); - ui.label(egui::RichText::new(line).color(t.text_dim)); - } - // offline originals / smart previews - // (cached answers, checked off the UI thread: unknown counts as online / no smart preview) - if let lightcraft_catalog::Source::File { path } = &p.source { - let avail = &app.session.media.availability; - let online = !avail.is_offline(path); - let smart = app.session.media.smart_dir.as_ref().is_some_and(|d| { - let shard = lightcraft_engine::smart::sharded_file_path(d, &p); - let legacy = d.join(lightcraft_engine::smart::file_name(&p)); - avail.exists(&shard.to_string_lossy()) == Some(true) || avail.exists(&legacy.to_string_lossy()) == Some(true) - }); - if !online || smart { - let text = match (online, smart) { - (false, true) => "Original offline · editing the smart preview", - (false, false) => "Original offline · no smart preview", - _ => "Smart preview available", - }; - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr(text)).color(if online { t.text_dim } else { t.accent })); - } - } - ui.add_space(10.0); - if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false) - .on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file")) - .clicked() - { - let _ = app.run("dialog.allMetadata", json!({})); - } - }); -} - -/// The camera card at the top of Info: camera, lens, size · file size and format, then the -/// capture settings. -fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { - let t = Tokens::get(ui.ctx()); - let m = &p.meta; - egui::Frame::NONE.fill(t.canvas).corner_radius(6.0).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| { - ui.set_width(ui.available_width()); - let dim = |s: &str| egui::RichText::new(s.to_string()).size(12.0).color(t.text_label); - if !m.camera.is_empty() { - ui.label(dim(&m.camera)); - } - if !m.lens.is_empty() { - ui.add(egui::Label::new(dim(&m.lens)).truncate()); - } - ui.horizontal(|ui| { - ui.label(dim(&format!("{} × {} · {}", p.width, p.height, human_size(p.file_size)))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - egui::Frame::NONE.fill(t.chrome).corner_radius(3.0).inner_margin(egui::Margin::symmetric(5, 1)).show(ui, |ui| { - ui.label(egui::RichText::new(p.format.to_uppercase()).size(10.5).color(t.text_label)); - }); - }); - }); - let rows = [ - ("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))), - ("Shutter speed", (!m.shutter.is_empty()).then(|| crate::i18n::tr_format!("{} sec", m.shutter))), - ("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))), - ("ISO", m.iso.map(|i| i.to_string())), - ]; - if rows.iter().any(|r| r.1.is_some()) { - ui.add_space(6.0); - egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| { - for (k, v) in rows { - ui.label(dim(crate::i18n::tr(k))); - ui.label(dim(&v.unwrap_or_else(|| "—".into()))); - ui.end_row(); - } - }); - } - // where the camera focused, and how (View ▸ Focus Point draws it): only when known; the - // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits - if let Some(text) = focus_text(m.focus.as_ref()) { - ui.add_space(3.0); - let r = ui.horizontal_wrapped(|ui| { - ui.label(dim(crate::i18n::tr("Focus"))); - ui.add_space(12.0); - ui.label(egui::RichText::new(text).size(12.0).color(t.text)); - }); - register(ui.ctx(), "info:focus", r.response.rect); - } - }); -} - -/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye -/// Tracking"), or `None` when the photo has no focus point. -pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option { - let f = f?; - let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); - Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) -} - -fn human_size(bytes: u64) -> String { - match bytes { - b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), - b if b >= 1 << 10 => format!("{} KB", b >> 10), - b => format!("{b} B"), - } -} - -/// A labelled metadata text field: the typed text lives in egui memory while focused and is -/// saved (photo.setMeta `key`) when the field loses focus. -/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). -fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); - let r = crate::widgets::combo_box("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { - for st in lightcraft_catalog::CopyrightStatus::ALL { - if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current { - let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); - } - } - }); - register(ui.ctx(), "field:copyrightStatus", r.response.rect); - ui.add_space(6.0); -} - -fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); - let id = egui::Id::new(("info-field", key)); - let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string()); - let widget = format!("field:{key}"); - let field = if lines > 1 { - crate::text_field::TextField::multiline(&widget, &mut text).rows(lines) - } else { - crate::text_field::TextField::singleline(&widget, &mut text) - }; - let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); - // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) - if r.editing { - ui.data_mut(|d| d.insert_temp(id, text.clone())); - } else { - ui.data_mut(|d| d.remove::(id)); - } - // Return (one line) or leaving saves; Esc gives the edit up - if r.committed() && text != value { - let _ = app.run("photo.setMeta", json!({key: text})); - } - ui.add_space(6.0); -} - -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) - if app.session.catalog.photo(id).is_none() { - return; - } - let top = ui.cursor().top(); - if fold_header(app, ui, "keywording", "Keywords") { - keywording(app, ui); - } - hold_list_in_place(ui, top); - super::keyword_list::show(app, ui); -} - -/// The Keywording box: the field that adds keywords, the selection's keywords, the keyword set, -/// the painter and the suggestions. -fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - let kid = egui::Id::new("kw-input"); - let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); - // the keywords every selected photo has: those only some have are still offered and suggested - let selection = app.session.selected(); - let have: Vec = - app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); - // while the box has the cursor it offers the library's keywords for what is being typed, - // right under itself; it stays for a click on one of them, which takes the cursor away - let field = crate::text_field::id("field:keyword"); - let oid = egui::Id::new("kw-offers"); - let pass = ui.ctx().cumulative_pass_nr(); - // (remembered from the last frame: the text the highlight was for and the keyword it was - // on, where the offers were drawn, and whether the box was in sight) - let (typed_then, at_then, drawn, drawn_pass, hidden): (String, Option, Rect, u64, bool) = - ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0, false)); - let last_frame = drawn_pass.checked_add(1) == Some(pass); - let clicking = last_frame - && ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p))); - let typed = super::keywording::typing(&text).to_string(); - // (none while the box is scrolled out of the panel: they would float over what is there) - let offers = ((ui.memory(|m| m.has_focus(field)) || clicking) && !(last_frame && hidden)) - .then(|| app.caches.completions(&app.session.catalog, &have, &typed)) - .filter(|o| !o.keywords.is_empty() || o.create.is_some()); - // ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes - // back to none: Return then gives what was typed, as without offers); Return gives the - // highlighted one. The highlight is on a keyword, not a line: it stays with it when the - // library changes under it, and goes when the keyword does. With Shift or Alt the keys - // are the text's own. - let mut at = at_then.filter(|k| typed_then == typed && offers.as_ref().is_some_and(|o| o.keywords.contains(k))); - let mut picked = None; - if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) { - let pressed = |ui: &mut egui::Ui, key: egui::Key| { - ui.input_mut(|i| { - let plain = - |e: &egui::Event| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.is_none()); - let found = i.events.iter().any(plain); - i.events.retain(|e| !plain(e)); - found - }) - }; - let line = at.as_ref().and_then(|k| o.keywords.iter().position(|x| x == k)); - if pressed(ui, egui::Key::ArrowDown) { - let next = line.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1)); - at = o.keywords.get(next).cloned(); - } else if pressed(ui, egui::Key::ArrowUp) { - at = line.and_then(|a| a.checked_sub(1)).and_then(|a| o.keywords.get(a).cloned()); - } - if let Some(k) = &at - && pressed(ui, egui::Key::Enter) - { - picked = Some(super::keywording::Picked::Keyword(k.clone())); - } - } - // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) - // (asked for on the frame before this one only: a box that wasn't drawn then, its panel - // switched away, doesn't claim the cursor whenever it is next drawn) - let bid = egui::Id::new("kw-begin"); - let begin = ui.data_mut(|d| d.remove_temp::(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); - let r = crate::text_field::TextField::singleline("field:keyword", &mut text) - .hint(crate::i18n::tr("Add keyword")) - .width(f32::INFINITY) - .begin_edit(begin) - .show(ui); - let in_sight = ui.clip_rect().contains(r.response.rect.center()); - let mut drawn = Rect::NOTHING; - // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) - if !in_sight { - picked = None; - } - if offers.is_none() { - // (not over the offers) - r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); - } - if let Some(o) = offers.as_ref().filter(|_| in_sight) { - let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at.as_deref()); - drawn = rect; - picked = picked.or(clicked); - } - ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass, !in_sight))); - // Return gives the keywords to the selected photos; Esc gives back what was there before - // this edit (the shared field does) - let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty(); - // a new name goes inside the default parent (Put New Keywords Inside This Keyword) - let as_typed = |app: &LightcraftApp, text: &str| -> Vec { - text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() - }; - let adding: Option> = match picked { - // an offer: it, and the keywords typed before the last comma - Some(super::keywording::Picked::Keyword(k)) => { - let mut kws = super::keywording::typed_before(&app.session.catalog, &text); - kws.push(k); - Some(kws) - } - Some(super::keywording::Picked::Typed) => Some(as_typed(app, &text)), - None if returned => Some(as_typed(app, &text)), - None => None, - }; - if let Some(kws) = adding { - // (refused, the text stays to be put right, and the refusal is said) - match app.run("photo.setMeta", json!({"addKeywords": kws})) { - Ok(_) => { - text.clear(); - // an offer taken leaves the cursor in the box, for the next keyword - if !returned { - ui.data_mut(|d| d.insert_temp(bid, pass)); - } - } - Err(e) => app.toast_error(ui.ctx(), e), - } - } - ui.data_mut(|d| d.insert_temp(kid, text)); - ui.add_space(8.0); - super::keywording::view_switch(app, ui); - ui.add_space(4.0); - super::keywording::scope(app, ui, &selection); - if app.ui.keywording_view == crate::state::KeywordingView::Keywords { - super::keywording::chip_row(app, ui, &selection); - } else { - super::keywording::names_row(app, ui); - } - ui.add_space(10.0); - keyword_set(app, ui); - ui.add_space(8.0); - // the painter: click photos in the grid to give them (or take away) a keyword - ui.horizontal(|ui| { - let pid = egui::Id::new("kw-painter"); - let painting = app.ui.keyword_painter.clone(); - let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default()); - // (fixed while painting: Stop first) - let field = ui - .add_enabled_ui(painting.is_none(), |ui| { - crate::text_field::TextField::singleline("field:keywordPainter", &mut k) - .hint(crate::i18n::tr("Keyword to paint")) - .width(130.0) - .show(ui) - }) - .inner; - ui.data_mut(|d| d.insert_temp(pid, k.clone())); - let label = if painting.is_some() { "Stop" } else { "Paint" }; - let returned = field.ending == Some(crate::text_field::Ending::Return) && painting.is_none() && !k.trim().is_empty(); - if text_button(ui, "keywordPaint", label, painting.is_some()) - .on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops")) - .clicked() - || returned - { - let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }})); - } - }); - ui.add_space(10.0); - // suggestions: keywords used together with the selection's keywords, else the most used - // ones (what matches the text being typed is offered under the box itself) - let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone(); - if !suggestions.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); - // grouped: the keyword's own name, its whole path in the tooltip - let grouped = app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped; - let names = if grouped { super::keywording::suggestion_names(&suggestions) } else { Vec::new() }; - ui.horizontal_wrapped(|ui| { - if grouped { - ui.spacing_mut().item_spacing = vec2(6.0, 4.0); - } - for (i, k) in suggestions.into_iter().enumerate() { - let whole = k.replace('|', " › "); - let name = names.get(i).unwrap_or(&whole); - let r = ui.add(egui::Button::new(egui::RichText::new(format!("+ {name}")).color(t.text_label)).corner_radius(10.0)); - let r = if grouped { r.on_hover_text(&whole) } else { r }; - register(ui.ctx(), format!("kwSuggest:{k}"), r.rect); - if r.clicked() { - let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); - } - } - }); - } - }); -} - -/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at -/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it -/// a long tree slides away from under the pointer by a row of chips each time. It holds only while -/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and -/// while the change does not come from the box itself (the pointer on it, or typing in its field). -fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { - let height = ui.cursor().top() - top; - let key = egui::Id::new("kw-box-height"); - let pass = ui.ctx().cumulative_pass_nr(); - // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) - let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); - ui.data_mut(|d| d.insert_temp(key, (height, pass))); - let Some(before) = before else { return }; - let grew = height - before; - if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { - return; - } - // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) - let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); - let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); - let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); - if on_box || typing { - return; - } - ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), (grew, pass))); - // (drawn again at once at the new offset, so the list never shows a frame out of place) - ui.ctx().request_discard("the Keyword List holds its place"); -} - -/// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the -/// selected photos; "Save as Set…" keeps the current nine under a name. -fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { - // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do - let selection = app.session.selected(); - let chips = app.caches.keyword_chips(&app.session.catalog, &selection); - let t = Tokens::get(ui.ctx()); - let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); - let current = sets["current"].as_str().unwrap_or_default().to_string(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); - let combo = crate::widgets::combo_box("kw-set") - .selected_text(crate::i18n::builtin_label(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) - .show_ui(ui, |ui| { - for set in sets["sets"].as_array().into_iter().flatten() { - let name = set["name"].as_str().unwrap_or_default(); - if ui - .selectable_label(name == current, crate::i18n::builtin_label(name, name == lightcraft_engine::cmd::keywords::RECENT)) - .clicked() - { - let _ = app.run("keyword.useSet", json!({"name": name})); - } - } - ui.separator(); - // Edit Set…: the current nine, slot by slot (named, Recent Keywords becomes a set) - let edit = ui.button(crate::i18n::tr("Edit Set…")); - register(ui.ctx(), "keywordSetMenu:edit", edit.rect); - if edit.clicked() { - let mut slots: Vec = - sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); - slots.resize(9, String::new()); - let named = current != lightcraft_engine::cmd::keywords::RECENT; - app.ui.dialog = Some(crate::state::Dialog::KeywordSet { - replaces: named.then(|| current.clone()), - name: if named { current.clone() } else { String::new() }, - slots, - as_new: false, - }); - } - if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: "Save Keyword Set".into(), - hint: "Set name".into(), - value: String::new(), - command: "keyword.saveSet".into(), - params: json!({}), - key: "name".into(), - }); - } - if current != lightcraft_engine::cmd::keywords::RECENT - && ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked() - { - let _ = app.run("keyword.deleteSet", json!({"name": current})); - } - }); - register(ui.ctx(), "keywordSetCombo", combo.response.rect); - }); - let kws: Vec = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); - if kws.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("Keywords you add appear here; ⌥1–⌥9 apply them.")).color(t.text_dim)); - return; - } - let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0); - egui::Grid::new("kw-set-grid").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { - for (i, k) in kws.iter().enumerate() { - // an empty slot: an idle button, so the others keep their ⌥ keys - if k.is_empty() { - let r = ui.add_enabled_ui(false, |ui| ui.add_sized([bw, 22.0], egui::Button::new(""))).inner; - register(ui.ctx(), format!("kwSetEmpty:{}", i + 1), r.rect); - if i % 3 == 2 { - ui.end_row(); - } - continue; - } - let chip = chips.iter().find(|c| lightcraft_catalog::keywords::same(&c.path, &lightcraft_catalog::keywords::clean(k))); - let on = chip.is_some_and(|c| c.on_all()); - let some = chip.is_some() && !on; - let short = format!("{}{}", k.rsplit('|').next().unwrap_or(k), if some { " *" } else { "" }); - let r = ui - .add_sized( - [bw, 22.0], - egui::Button::new(egui::RichText::new(short).size(11.5).color(if on { t.text } else { t.text_label })).selected(on).truncate(), - ) - .on_hover_text(format!("{} — ⌥{}", k.replace('|', " › "), i + 1)); - register(ui.ctx(), format!("kwSet:{}", i + 1), r.rect); - if on { - register(ui.ctx(), format!("kwSetOn:{}", i + 1), r.rect); - } else if some { - register(ui.ctx(), format!("kwSetSome:{}", i + 1), r.rect); - } - if r.clicked() { - let _ = app.run("keyword.toggleFromSet", json!({"index": i + 1})); - } - if i % 3 == 2 { - ui.end_row(); - } - } - }); -} - -fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let Some(p) = app.session.catalog.photo(id).cloned() else { return }; - header(ui, "Versions"); - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() { - let _ = app.run("version.create", json!({})); - } - ui.add_space(8.0); - // Named (made by you) and Auto (made by LightCraft) versions - let tab_id = egui::Id::new("versions-tab"); - let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false); - let named_n = p.versions.iter().filter(|v| !v.auto).count(); - let items = [("Named", "named"), ("Auto", "auto")]; - let labels = - [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)]; - let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect(); - if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) { - auto = i == 1; - ui.data_mut(|d| d.insert_temp(tab_id, auto)); - } - ui.add_space(6.0); - let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect(); - if shown.is_empty() { - let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." }; - ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim)); - } - // newest first - for (i, v) in shown.into_iter().rev() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); - crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1)); - register(ui.ctx(), format!("version:{i}"), r); - let current = *v.settings == *p.develop; - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); - } - // thumbnail of the version's look - let tr = Rect::from_min_size(r.min + vec2(4.0, 5.0), vec2(72.0, 48.0)); - ui.painter().rect_filled(tr, 2.0, t.canvas); - if let Some(job) = app.session.variant_job(id, &v.settings, 160) - && let Some(tex) = app.renderer.variant(job) - { - ui.painter().image(tex.tex.id(), tr, crate::panels::presets::cover_uv(tr, tex.size), egui::Color32::WHITE); - } - let x = tr.right() + 10.0; - ui.painter().with_clip_rect(r.with_max_x(r.right() - 24.0)).text( - pos2(x, r.top() + 18.0), - Align2::LEFT_CENTER, - &v.name, - t.semibold(13.0), - t.text, - ); - let painter = ui.painter().with_clip_rect(r); - painter.text(pos2(x, r.top() + 38.0), Align2::LEFT_CENTER, short_time(&v.created), t.font(11.0), t.text_dim); - if current { - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Check, t.accent); - } - // resting on a version shows it in the loupe (nothing is changed) - if resp.hovered() && !current { - app.hover_preview = - Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() }); - } - if resp.double_clicked() { - crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); - } else if resp.clicked() && !current { - let _ = app.run("version.restore", json!({"index": i})); - } - let resp = resp.on_hover_text(crate::i18n::tr(if current { - "The photo has these settings" - } else { - "Click to restore · double-click to rename" - })); - crate::widgets::context_menu(&resp, |ui| { - if ui.button(crate::i18n::tr("Restore")).clicked() { - let _ = app.run("version.restore", json!({"index": i})); - } - if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() { - let _ = app.run("version.update", json!({"index": i})); - } - if ui.button(crate::i18n::tr("Rename…")).clicked() { - crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); - } - if ui.button(crate::i18n::tr("Set as Before")).clicked() { - let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Delete")).clicked() { - let _ = app.run("version.delete", json!({"index": i})); - } - }); - } - }); -} - -/// "Sep 30, 2026, 12:00 PM" from an ISO time. -fn short_time(iso: &str) -> String { - // Other languages use their own date patterns (`i18n::display_time`). - if crate::i18n::language() != crate::i18n::Locale::En { - return crate::i18n::display_time(iso); - } - let long = lightcraft_catalog::dates::display_time(iso); - // "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped - let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t))); - let date = match date.split_once(' ') { - Some((m, rest)) => format!("{} {rest}", m.chars().take(3).collect::()), - None => date.to_string(), - }; - match time.and_then(|t| t.rsplit_once(' ')) { - Some((hms, ampm)) => format!("{date}, {} {ampm}", hms.rsplit_once(':').map_or(hms, |x| x.0)), - None => date, - } -} - -/// How a History row is drawn: the step the settings are at is highlighted, the steps after it -/// (still there to return to until a new edit) are greyed out. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum HistoryRow { - Done, - Current, - Undone, -} - -fn history_row(i: usize, cursor: Option) -> HistoryRow { - match cursor { - Some(c) if i == c => HistoryRow::Current, - Some(c) if i > c => HistoryRow::Undone, - _ => HistoryRow::Done, - } -} - -fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let Some(p) = app.session.catalog.photo(id).cloned() else { return }; - header(ui, "History"); - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - if p.history.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); - } - let cursor = p.history_index(); - for (i, h) in p.history.iter().enumerate().rev() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr(&h.label)); - register(ui.ctx(), format!("history:{i}"), r); - let row = history_row(i, cursor); - if row == HistoryRow::Current { - ui.painter().rect_filled(r, 3.0, t.pressed); - } else if resp.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); - } - let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) }; - paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon); - ui.painter().text( - pos2(r.left() + 24.0, r.center().y), - Align2::LEFT_CENTER, - crate::i18n::history_label(&h.label, &app.session.presets), - t.font(12.5), - text, - ); - if resp.clicked() { - let _ = app.run("history.restore", json!({"index": i})); - } - crate::widgets::context_menu(&resp, |ui| { - if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() { - let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i})); - } - if ui.button(crate::i18n::tr("Create Version from Step")).clicked() { - let _ = app.run("history.restore", json!({"index": i})); - let _ = app.run("version.create", json!({"name": h.label})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Clear History")).clicked() { - let _ = app.run("history.clear", json!({})); - } - }); - } - }); -} - #[cfg(test)] -mod tests { - // Feature: the History panel greys out the steps after the cursor - #[test] - fn history_rows_after_the_cursor_are_greyed() { - use super::{HistoryRow, history_row}; - let rows: Vec<_> = (0..4).map(|i| history_row(i, Some(1))).collect(); - assert_eq!(rows, [HistoryRow::Done, HistoryRow::Current, HistoryRow::Undone, HistoryRow::Undone]); - assert_eq!(history_row(3, Some(3)), HistoryRow::Current, "at the latest step nothing is greyed"); - assert_eq!(history_row(0, None), HistoryRow::Done); - } - - // Feature: the aspect button tells what is locked - #[test] - fn aspect_label_names_presets_in_either_orientation() { - assert_eq!(super::aspect_label(None, Some(1.5)), "Free"); - assert_eq!(super::aspect_label(Some((1600, 900)), None), "16 × 9"); - assert_eq!(super::aspect_label(Some((900, 1600)), None), "16 × 9", "portrait 16:9"); - assert_eq!(super::aspect_label(Some((400, 500)), None), "4 × 5 / 8 × 10"); - } - - #[test] - fn aspect_label_prefers_original_over_a_coinciding_preset_and_never_shows_pixel_sizes() { - assert_eq!(super::aspect_label(Some((300, 200)), Some(1.5)), "Original"); - assert_eq!(super::aspect_label(Some((600_000, 400_000)), Some(1.5)), "Original"); - assert_eq!(super::aspect_label(Some((137, 100)), Some(1.5)), "1.37 : 1"); - assert_eq!(super::aspect_label(Some((0, 5)), None), "Free"); - } - - #[test] - fn short_times() { - assert_eq!(super::short_time("2026-09-30T12:00:05"), "Sep 30, 2026, 12:00 PM"); - assert_eq!(super::short_time("2026-01-02"), "Jan 2, 2026"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/right/crop.rs b/crates/ui-egui/src/panels/right/crop.rs new file mode 100644 index 000000000..67efd91f6 --- /dev/null +++ b/crates/ui-egui/src/panels/right/crop.rs @@ -0,0 +1,483 @@ +use super::*; + +/// The aspect presets of the Crop panel: (menu label, `crop.aspect` value). +const ASPECT_PRESETS: [(&str, &str); 10] = [ + ("Free", "free"), + ("Original", "original"), + ("1 × 1", "1x1"), + ("4 × 5 / 8 × 10", "4x5"), + ("8.5 × 11", "8.5x11"), + ("5 × 7", "5x7"), + ("2 × 3 / 4 × 6", "2x3"), + ("4 × 3", "4x3"), + ("16 × 9", "16x9"), + ("16 × 10", "16x10"), +]; + +/// What the aspect button says for the stored lock (`aspect` = width × 100, height × 100): +/// a preset's name when the ratio matches one in either orientation, "Original" for the photo's own +/// shape, otherwise the ratio as `1.37 : 1`. +pub(super) fn aspect_label(aspect: Option<(u32, u32)>, original: Option) -> String { + let Some((w, h)) = aspect else { return "Free".into() }; + if w == 0 || h == 0 { + return "Free".into(); + } + let r = w as f64 / h as f64; + let is = |q: f64| q.is_finite() && q > 0.0 && ((r - q).abs() / q < 0.004 || (1.0 / r - q).abs() / q < 0.004); + if original.is_some_and(is) { + return "Original".into(); + } + for (label, key) in ASPECT_PRESETS { + if let Some((x, y)) = key.split_once('x') + && let (Ok(x), Ok(y)) = (x.parse::(), y.parse::()) + && is(x / y) + { + return label.to_string(); + } + } + format!("{:.2} : 1", r.max(1.0 / r)) +} + +/// "Custom" row of the aspect menu: two number fields and an Apply button (`crop.aspect` `[w, h]`). +fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let key = egui::Id::unique("crop-custom-aspect"); + let (mut w, mut h): (String, String) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_else(|| ("3".into(), "2".into())); + let mut apply = false; + ui.horizontal(|ui| { + ui.label(crate::i18n::tr("Custom")); + let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width")); + register(ui, "field:cropCustomW", a.rect); + ui.label("×"); + let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height")); + register(ui, "field:cropCustomH", b.rect); + let go = text_button(ui, "cropCustomApply", "Apply", false); + apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter))); + }); + if apply { + // Accept `3`, `2.5` and `3,5`; anything else (zero, negative, text, huge) is ignored. + let num = |t: &str| t.trim().replace(',', ".").parse::().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 1000.0); + if let (Some(x), Some(y)) = (num(&w), num(&h)) + && (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&(x / y)) + { + let _ = app.run("crop.aspect", json!({"aspect": [x, y]})); + ui.close(); + } + } + ui.data_mut(|d| d.insert_temp(key, (w, h))); +} + +pub(super) fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); + header(ui, "Crop"); + padded(ui, |ui| { + let original = app.session.catalog.photo(id).map(|p| { + let (w, h) = (p.width.max(1) as f64, p.height.max(1) as f64); + if d.orientation.swaps_axes() { h / w } else { w / h } + }); + ui.horizontal(|ui| { + ui.label(crate::i18n::tr("Aspect Ratio")); + let cur = aspect_label(app.crop_tool_aspect(), original); + let t = Tokens::get(ui.ctx()); + let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text); + + egui::Popup::menu(&r).show(|ui| { + for (label, a) in ASPECT_PRESETS { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run("crop.aspect", json!({"aspect": a})); + } + } + ui.separator(); + custom_aspect(app, ui); + }); + let locked = app.crop_tool_aspect().is_some(); + if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() { + let _ = app.run("crop.aspect", json!({"aspect": "toggle"})); + } + }); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if text_button(ui, "cropRotateLeft", crate::i18n::tr("Rotate Left"), false).clicked() { + let _ = app.run("photo.rotateLeft", json!({})); + } + if text_button(ui, "cropRotateRight", crate::i18n::tr("Rotate Right"), false).clicked() { + let _ = app.run("photo.rotateRight", json!({})); + } + }); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if text_button(ui, "cropFlipH", crate::i18n::tr("Flip H"), d.crop.flip_h).clicked() { + let _ = app.run("photo.flipHorizontal", json!({})); + } + if text_button(ui, "cropFlipV", crate::i18n::tr("Flip V"), d.crop.flip_v).clicked() { + let _ = app.run("photo.flipVertical", json!({})); + } + if text_button(ui, "cropSwapAspect", crate::i18n::tr("Swap Aspect (X)"), false).clicked() { + let _ = app.run("crop.rotateAspect", json!({})); + } + if text_button(ui, "cropReset", crate::i18n::tr("Reset"), false).clicked() { + let _ = app.run("crop.reset", json!({})); + } + }); + }); + let ang = lightcraft_develop::controls::find("crop.angle").copied(); + if let Some(spec) = ang { + let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten")); + crate::panels::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v}))); + // an exact angle, typed (issue #534): a field of its own, besides the slider's value + padded(ui, |ui| { + ui.horizontal(|ui| { + ui.label(crate::i18n::tr("Angle")); + let mut angle = d.crop.geometry.angle; + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let r = ui.add( + egui::DragValue::new(&mut angle) + .range(spec.min..=spec.max) + .clamp_existing_to_range(false) + .speed(0.05) + .fixed_decimals(2) + // as the readout writes it: "+2.50", and every zero "0.00" + .custom_formatter(|v, _| crate::panels::detail::crop_angle_label(v).trim_end_matches('°').to_string()) + .suffix("°") + // read as the Straighten value reads a typed one: "-3,25" too + .custom_parser(|text| crate::widgets::typed_value(&spec, text.trim().trim_end_matches('°'))) + // a typed angle applies on Return (or leaving the field), not keystroke by + // keystroke: no half-typed angles, one undo step + .update_while_editing(false), + ); + crate::widgets::register(ui, "cropAngleField", r.rect); + // a drag on the field is one undo step, like the slider's + if r.drag_started() { + let _ = app.run("develop.beginInteraction", json!({"label": "Straighten"})); + } + // egui reports a change while text is typed even when the value isn't updated yet: + // apply only a new angle + // Esc keeps the old angle, but egui applies the typed text on the frame after the + // one where Esc took the focus away: remember the Esc for that frame + let cancel = egui::Id::unique("cropAngleFieldEscaped"); + let escaped = ui.data_mut(|m| m.remove_temp::(cancel)).unwrap_or(false); + if escape && r.lost_focus() { + ui.data_mut(|m| m.insert_temp(cancel, true)); + } + if r.changed() && !escaped && angle.is_finite() && angle != d.crop.geometry.angle { + let _ = app.run("crop.straighten", json!({"angle": (angle.clamp(spec.min, spec.max) * 100.0).round() / 100.0})); + } + if r.drag_stopped() { + let _ = app.run("develop.endInteraction", json!({})); + } + }); + }); + } + padded(ui, |ui| { + ui.horizontal(|ui| { + let on = app.ui.tool == "straighten"; + if text_button(ui, "straightenTool", crate::i18n::tr("Straighten Tool"), on) + .on_hover_text(crate::i18n::tr("Drag along the horizon; double-click for Auto")) + .clicked() + { + app.ui.tool = if on { String::new() } else { "straighten".into() }; + } + if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() { + app.auto_straighten(ui.ctx()); + } + }); + }); + padded(ui, |ui| { + ui.label(crate::i18n::tr("Overlay")); + { + use crate::state::CropOverlay as O; + let opts = [ + (O::Thirds, "Thirds", "thirds"), + (O::Grid, "Grid", "grid"), + (O::Golden, "Golden", "golden"), + (O::Diagonal, "Diagonal", "diagonal"), + (O::Triangle, "Triangle", "triangle"), + (O::Spiral, "Spiral", "spiral"), + (O::None, "None", "none"), + ]; + let items: Vec<(&str, &str)> = opts.iter().map(|(_, l, k)| (*l, *k)).collect(); + let active = opts.iter().position(|(o, _, _)| *o == app.ui.crop_overlay); + if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) { + app.ui.crop_overlay = opts[i].0; + } + if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) + && text_button(ui, "cropOverlayOrient", crate::i18n::tr("Flip Overlay (⇧O)"), false).clicked() + { + let _ = app.run("view.cropOverlayOrientation", json!({})); + } + } + }); + divider(ui); + header(ui, "Geometry"); + padded(ui, |ui| { + ui.label(crate::i18n::tr("Upright")); + { + use lightcraft_develop::Upright; + let modes = [ + ("Off", Upright::Off, "off"), + ("Auto", Upright::Auto, "auto"), + ("Guided", Upright::Guided, "guided"), + ("Level", Upright::Level, "level"), + ("Vertical", Upright::Vertical, "vertical"), + ("Full", Upright::Full, "full"), + ]; + let items: Vec<(&str, &str)> = modes.iter().map(|(l, _, k)| (*l, *k)).collect(); + let active = modes.iter().position(|(_, m, _)| *m == d.geometry.upright); + if let Some(i) = crate::widgets::segmented(ui, "upright", &items, active, 3) { + let (_, mode, key) = modes[i]; + let _ = app.run("geometry.upright", json!({"mode": key})); + app.ui.tool = if mode == Upright::Guided { "guidedUpright".into() } else { String::new() }; + } + } + let guided = d.geometry.upright == lightcraft_develop::Upright::Guided; + ui.horizontal_wrapped(|ui| { + if !guided + && d.geometry.upright != lightcraft_develop::Upright::Off + && text_button(ui, "uprightUpdate", crate::i18n::tr("Update"), false).clicked() + { + let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default(); + let _ = app.run("geometry.upright", json!({"mode": mode})); + } + if guided { + let drawing = app.ui.tool == "guidedUpright"; + if text_button(ui, "uprightDraw", crate::i18n::tr("Draw Guides"), drawing).clicked() { + app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() }; + } + if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", crate::i18n::tr("Clear Guides"), false).clicked() { + let _ = app.run("geometry.guides", json!({"guides": []})); + } + } + }); + if guided { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{} of 4 guides — drag along lines that should be vertical or horizontal.", + d.geometry.guides.len() + )) + .size(11.0) + .color(Tokens::get(ui.ctx()).text_dim), + ); + } + ui.add_space(4.0); + let mut c = d.geometry.constrain_crop; + if ui.checkbox(&mut c, crate::i18n::tr("Constrain Crop")).changed() { + let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"})); + } + }); + for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") { + let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default); + let out = slider(ui, spec, v, true, None); + crate::panels::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v}))); + } +} + +pub(super) fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); + header(ui, "Remove"); + padded(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for (label, tool) in [("Remove", "remove"), ("Content-Aware", "fill"), ("Heal", "heal"), ("Clone", "clone")] { + if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() { + app.ui.tool = tool.into(); + } + } + }); + ui.add_space(8.0); + ui.label(crate::i18n::tr_format!("{} spot(s) on this photo", d.spots.len())); + ui.add_space(4.0); + if text_button(ui, "findDust", crate::i18n::tr("Find Dust Spots"), false) + .on_hover_text(crate::i18n::tr("Heal the small, soft dark spots sensor dust leaves on smooth areas")) + .clicked() + { + match app.run("spot.findDust", json!({})) { + Ok(r) => { + let n = r["added"].as_u64().unwrap_or(0); + app.toast( + ui.ctx(), + if n == 0 { + crate::i18n::tr("No dust spots found").to_string() + } else { + crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n) + }, + ); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("Paint over a distraction on the photo to remove it.")).color(Tokens::get(ui.ctx()).text_dim)); + }); + // brush settings; with a spot selected they edit that spot too + let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone()))); + if let Some((i, sp)) = &sel { + (app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32); + divider(ui); + let mode = match sp.mode { + lightcraft_develop::SpotMode::Heal => "Heal", + lightcraft_develop::SpotMode::Clone => "Clone", + lightcraft_develop::SpotMode::Remove => "Remove", + lightcraft_develop::SpotMode::Fill => "Content-Aware", + }; + crate::panels::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = crate::i18n::tr(mode))); + } + let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { + id, + label, + section: lightcraft_develop::Section::Detail, + min, + max, + default, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, + }; + let sliders = [ + (plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64), + (plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64), + (plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64), + ]; + for (spec, key, v) in sliders { + let out = slider(ui, &spec, v, true, None); + let scale = if key == "size" { 1000.0 } else { 1.0 }; + if let Some(v) = out.value { + match key { + "size" => app.ui.remove_size = (v / scale) as f32, + "feather" => app.ui.remove_feather = v as f32, + _ => app.ui.remove_opacity = v as f32, + } + } + if sel.is_some() { + crate::panels::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale}))); + } + } + if sel.is_some() { + padded(ui, |ui| { + ui.horizontal(|ui| { + if text_button(ui, "spotRefresh", crate::i18n::tr("Refresh Source (/)"), false).clicked() { + let _ = app.run("spot.refreshSource", json!({})); + } + if text_button(ui, "spotDelete", crate::i18n::tr("Delete (⌫)"), false).clicked() { + let _ = app.run("spot.delete", json!({})); + } + }); + }); + } + // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out + padded(ui, |ui| { + let mut v = app.ui.visualize_spots; + if ui.checkbox(&mut v, crate::i18n::tr("Visualize Spots (A)")).changed() { + app.ui.visualize_spots = v; + } + }); + let spec = lightcraft_develop::ControlSpec { + id: "ui.spotsThreshold", + label: "Threshold", + section: lightcraft_develop::Section::Detail, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, + }; + let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None); + if let Some(v) = out.value { + app.ui.spots_threshold = v as f32; + } + padded(ui, |ui| { + tool_overlay_options(app, ui); + ui.add_space(4.0); + if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() { + for i in (0..d.spots.len()).rev() { + let _ = app.run("spot.delete", json!({"index": i})); + } + } + }); +} + +/// Tool Overlay: when the spot and mask pins show on the photo (Auto / Always / Selected / Never; +/// Shift+H steps through them). Remove and Masking panels. +pub(crate) fn tool_overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use crate::state::ToolOverlay; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Tool Overlay")).color(t.text_dim)); + let current = app.ui.tool_overlay; + let r = crate::widgets::dropdown(ui, "toolOverlay", crate::i18n::tr(current.label()), t.font(12.5), t.text_label) + .on_hover_text(crate::i18n::tr("When pins show on the photo (Shift+H cycles)")); + egui::Popup::menu(&r).show(|ui| { + for m in ToolOverlay::ALL { + let item = ui.selectable_label(m == current, crate::i18n::tr(m.label())); + register(ui, format!("toolOverlay:{}", m.name()), item.rect); + if item.clicked() { + let _ = app.run("view.toolOverlay", json!({"mode": m.name()})); + } + } + }); + }); +} + +pub(super) fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); + header(ui, "Red Eye"); + padded(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for (label, pet) in [("Red Eye", false), ("Pet Eye", true)] { + if text_button(ui, &format!("eyeMode-{}", if pet { "pet" } else { "red" }), label, app.ui.eye_pet == pet).clicked() { + app.ui.eye_pet = pet; + } + } + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(crate::i18n::tr("Drag over an eye on the photo; the pupil inside is found automatically.")) + .color(Tokens::get(ui.ctx()).text_dim), + ); + }); + let n = d.red_eye.len(); + if n == 0 { + return; + } + if app.ui.eye >= n { + app.ui.eye = n - 1; + } + let i = app.ui.eye; + let eye = d.red_eye[i]; + divider(ui); + crate::panels::edit::sub_title( + ui, + &crate::i18n::tr_format!( + "{label} {index} of {n}", + label = crate::i18n::tr(if eye.pet { "Pet Eye" } else { "Red Eye" }), + index = i + 1, + n = n + ), + ); + for k in ["pupilSize", "darken"] { + let cid = format!("redEye.{i}.{k}"); + let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue }; + let v = lightcraft_develop::controls::get(&d, &cid).unwrap_or(spec.default); + let out = slider(ui, spec, v, true, None); + crate::panels::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": cid, "value": v}))); + } + padded(ui, |ui| { + if eye.pet { + let mut on = eye.catchlight.is_some(); + if ui.checkbox(&mut on, crate::i18n::tr("Add Catchlight")).changed() { + let _ = app.run("redeye.catchlight", json!({"index": i, "on": on})); + } + ui.add_space(4.0); + } + ui.horizontal(|ui| { + if text_button(ui, "eyeDelete", crate::i18n::tr("Delete"), false).clicked() { + let _ = app.run("redeye.delete", json!({"index": i})); + } + if n > 1 && text_button(ui, "eyeDeleteAll", crate::i18n::tr("Delete all"), false).clicked() { + for k in (0..n).rev() { + let _ = app.run("redeye.delete", json!({"index": k})); + } + } + }); + }); +} diff --git a/crates/ui-egui/src/panels/right/history.rs b/crates/ui-egui/src/panels/right/history.rs new file mode 100644 index 000000000..49229783b --- /dev/null +++ b/crates/ui-egui/src/panels/right/history.rs @@ -0,0 +1,179 @@ +use super::*; + +pub(super) fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let Some(p) = app.session.catalog.photo(id).cloned() else { return }; + header(ui, "Versions"); + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() { + let _ = app.run("version.create", json!({})); + } + ui.add_space(8.0); + // Named (made by you) and Auto (made by LightCraft) versions + let tab_id = egui::Id::unique("versions-tab"); + let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false); + let named_n = p.versions.iter().filter(|v| !v.auto).count(); + let items = [("Named", "named"), ("Auto", "auto")]; + let labels = + [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)]; + let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect(); + if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) { + auto = i == 1; + ui.data_mut(|d| d.insert_temp(tab_id, auto)); + } + ui.add_space(6.0); + let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect(); + if shown.is_empty() { + let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." }; + ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim)); + } + // newest first + for (i, v) in shown.into_iter().rev() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1)); + register(ui, format!("version:{i}"), r); + let current = *v.settings == *p.develop; + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + // thumbnail of the version's look + let tr = Rect::from_min_size(r.min + vec2(4.0, 5.0), vec2(72.0, 48.0)); + ui.painter().rect_filled(tr, 2.0, t.canvas); + if let Some(job) = app.session.variant_job(id, &v.settings, 160) + && let Some(tex) = app.renderer.variant(job) + { + ui.painter().image(tex.tex.id(), tr, crate::panels::presets::cover_uv(tr, tex.size), egui::Color32::WHITE); + } + let x = tr.right() + 10.0; + ui.painter().with_clip_rect(r.with_max_x(r.right() - 24.0)).text( + pos2(x, r.top() + 18.0), + Align2::LEFT_CENTER, + &v.name, + t.semibold(13.0), + t.text, + ); + let painter = ui.painter().with_clip_rect(r); + painter.text(pos2(x, r.top() + 38.0), Align2::LEFT_CENTER, short_time(&v.created), t.font(11.0), t.text_dim); + if current { + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Check, t.accent); + } + // resting on a version shows it in the loupe (nothing is changed) + if resp.hovered() && !current { + app.hover_preview = + Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() }); + } + if resp.double_clicked() { + crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); + } else if resp.clicked() && !current { + let _ = app.run("version.restore", json!({"index": i})); + } + let resp = resp.on_hover_text(crate::i18n::tr(if current { + "The photo has these settings" + } else { + "Click to restore · double-click to rename" + })); + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Restore")).clicked() { + let _ = app.run("version.restore", json!({"index": i})); + } + if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() { + let _ = app.run("version.update", json!({"index": i})); + } + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); + } + if ui.button(crate::i18n::tr("Set as Before")).clicked() { + let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete")).clicked() { + let _ = app.run("version.delete", json!({"index": i})); + } + }); + } + }); +} + +/// "Sep 30, 2026, 12:00 PM" from an ISO time. +pub(super) fn short_time(iso: &str) -> String { + // Other languages use their own date patterns (`i18n::display_time`). + if crate::i18n::language() != crate::i18n::Locale::En { + return crate::i18n::display_time(iso); + } + let long = lightcraft_catalog::dates::display_time(iso); + // "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped + let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t))); + let date = match date.split_once(' ') { + Some((m, rest)) => format!("{} {rest}", m.chars().take(3).collect::()), + None => date.to_string(), + }; + match time.and_then(|t| t.rsplit_once(' ')) { + Some((hms, ampm)) => format!("{date}, {} {ampm}", hms.rsplit_once(':').map_or(hms, |x| x.0)), + None => date, + } +} + +/// How a History row is drawn: the step the settings are at is highlighted, the steps after it +/// (still there to return to until a new edit) are greyed out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HistoryRow { + Done, + Current, + Undone, +} + +pub(super) fn history_row(i: usize, cursor: Option) -> HistoryRow { + match cursor { + Some(c) if i == c => HistoryRow::Current, + Some(c) if i > c => HistoryRow::Undone, + _ => HistoryRow::Done, + } +} + +pub(super) fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let Some(p) = app.session.catalog.photo(id).cloned() else { return }; + header(ui, "History"); + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + if p.history.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); + } + let cursor = p.history_index(); + for (i, h) in p.history.iter().enumerate().rev() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&h.label)); + register(ui, format!("history:{i}"), r); + let row = history_row(i, cursor); + if row == HistoryRow::Current { + ui.painter().rect_filled(r, 3.0, t.pressed); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon); + ui.painter().text( + pos2(r.left() + 24.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::history_label(&h.label, &app.session.presets), + t.font(12.5), + text, + ); + if resp.clicked() { + let _ = app.run("history.restore", json!({"index": i})); + } + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() { + let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i})); + } + if ui.button(crate::i18n::tr("Create Version from Step")).clicked() { + let _ = app.run("history.restore", json!({"index": i})); + let _ = app.run("version.create", json!({"name": h.label})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Clear History")).clicked() { + let _ = app.run("history.clear", json!({})); + } + }); + } + }); +} diff --git a/crates/ui-egui/src/panels/right/info.rs b/crates/ui-egui/src/panels/right/info.rs new file mode 100644 index 000000000..4d7c198a0 --- /dev/null +++ b/crates/ui-egui/src/panels/right/info.rs @@ -0,0 +1,467 @@ +use super::*; + +pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let Some(p) = app.session.catalog.photo(id).cloned() else { return }; + header(ui, "Info"); + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + camera_card(ui, &p); + ui.add_space(6.0); + if let Some(why) = &p.preview_only { + if crate::widgets::preview_only_notice(ui, "info", why) { + crate::panels::edit::reload_preview_only(app, ui.ctx(), id); + } + ui.add_space(6.0); + } + if let Some(name) = &p.copy_name { + let of = p + .copy_of + .and_then(|m| app.session.catalog.photo(m)) + .map(|m| m.file_name.clone()) + .unwrap_or_else(|| crate::i18n::tr("a removed photo").into()); + ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label)); + } + ui.add_space(8.0); + if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner { + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); + } + ui.add_space(6.0); + // colour label: one swatch per label (hover shows its name); clicking the current one clears it + ui.horizontal(|ui| { + for l in lightcraft_catalog::ColorLabel::ALL { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}"))); + let key = format!("{l:?}").to_lowercase(); + register(ui, format!("label:{key}"), r); + let on = p.label == Some(l); + ui.painter().circle_filled(r.center(), if on || resp.hovered() { 8.0 } else { 6.5 }, crate::panels::grid::label_color(l)); + if on { + ui.painter().circle_stroke(r.center(), 10.0, egui::Stroke::new(1.5, t.text)); + } + let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l)); + if resp.clicked() { + // in Compare / Survey only the active photo, as the rating keys and the bottom pill do + let mut params = json!({"label": if on { "none".to_string() } else { key }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.label", params); + } + } + if let Some(l) = p.label { + ui.label(egui::RichText::new(app.session.catalog.label_name(l)).color(t.text_label)); + } + }); + }); + divider(ui); + // with several photos selected: Target Photo shows this photo's values, Selected Photos what + // the selection shares (a field where they differ is empty and says Mixed) + let selected = app.session.selected(); + let several = selected.len() > 1; + let for_selected = several && app.ui.settings.info_selected_photos; + let m = shown_meta(&app.session.catalog, &p, &selected, for_selected); + let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; + // the photos an edit goes to: Target Photo edits the photo it shows + let targets = if several && !for_selected { vec![id] } else { selected.clone() }; + padded(ui, |ui| { + if several { + metadata_for(app, ui); + } + for (label, key, value, lines) in [ + ("Title", "title", &m.title, 1), + ("Caption", "caption", &m.caption, 2), + ("Alt Text", "altText", &m.alt_text, 2), + ("Extended Description", "extendedDescription", &m.extended_description, 3), + ("Copyright", "copyright", &m.copyright, 1), + ] { + meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); + } + copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"), &targets); + for (label, key, value, lines) in [ + ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), + ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), + ("Creator", "creator", &m.creator, 1), + ] { + meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); + } + // file: name (rename), path (reveal), capture time (edit) + let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim)); + small(ui, "File Name"); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() { + let _ = app.run("dialog.rename", json!({})); + } + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(&p.file_name).color(t.text)).truncate()); + }); + }); + ui.add_space(4.0); + small(ui, "File Path"); + ui.horizontal(|ui| { + let path = match &p.source { + lightcraft_catalog::Source::File { path } => path.clone(), + lightcraft_catalog::Source::Demo { .. } => crate::i18n::tr("Generated demo photo").into(), + }; + ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate()); + if crate::menus::ui_enabled(app, "app.showInFinder") { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, crate::menus::reveal_label()) + .clicked() + { + let _ = app.run("app.showInFinder", json!({})); + } + }); + } + }); + ui.add_space(4.0); + small(ui, "Captured"); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() { + let _ = app.run("dialog.captureTime", json!({})); + } + let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into()); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate()); + }); + }); + ui.add_space(6.0); + for (label, key, value) in [ + ("Location", "location", &m.location), + ("City", "city", &m.city), + ("State / Province", "state", &m.state), + ("Country", "country", &m.country), + ] { + meta_field(app, ui, label, key, value, 1, mixed.contains(&key), &targets); + } + // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it + let gps_mixed = mixed.contains(&"gps"); + let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; + let gid = egui::Id::unique("info-gps"); + ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets)); + let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); + let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui); + edit.typed |= edit.text != edit.start; + if r.editing { + ui.data_mut(|d| d.insert_temp(gid, edit.clone())); + } else { + ui.data_mut(|d| d.remove::(gid)); + } + // Return or leaving saves; Esc gives the edit up + let text = edit.text.trim(); + if r.committed() + && (text != edit.start || edit.clears()) + && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) + { + app.toast(ui.ctx(), e); + } + // (one photo's place: not shown while the selected photos' places differ) + if let Some((la, lo)) = m.gps.filter(|_| !gps_mixed) { + let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); + if text_button(ui, "showOnMap", crate::i18n::tr("Show on Map"), false) + .on_hover_text(crate::i18n::tr("Open the place in OpenStreetMap")) + .clicked() + { + let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}"); + if let Err(e) = crate::links::open(app, &url) { + app.toast(ui.ctx(), e); + } + } + }); + if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) + .on_hover_text(format!( + "{}\n{}", + crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)"), + crate::i18n::tr("The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.") + )) + .clicked() + && let Err(e) = app.run("metadata.reverseGeocode", json!({})) + { + app.toast_error(ui.ctx(), e); + } + } + if let Some(a) = p.analysis { + let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness); + if a.clipped > 0.02 { + line.push_str(&crate::i18n::tr_format!(" · {:.0}% clipped", a.clipped * 100.0)); + } + if a.group.is_some() { + line.push_str(if a.best { " · best of its burst" } else { " · in a burst" }); + } + ui.add_space(4.0); + ui.label(egui::RichText::new(line).color(t.text_dim)); + } + // offline originals / smart previews + // (cached answers, checked off the UI thread: unknown counts as online / no smart preview) + if let lightcraft_catalog::Source::File { path } = &p.source { + let avail = &app.session.media.availability; + let online = !avail.is_offline(path); + let smart = app.session.media.smart_dir.as_ref().is_some_and(|d| { + let shard = lightcraft_engine::smart::sharded_file_path(d, &p); + let legacy = d.join(lightcraft_engine::smart::file_name(&p)); + avail.exists(&shard.to_string_lossy()) == Some(true) || avail.exists(&legacy.to_string_lossy()) == Some(true) + }); + if !online || smart { + let text = match (online, smart) { + (false, true) => "Original offline · editing the smart preview", + (false, false) => "Original offline · no smart preview", + _ => "Smart preview available", + }; + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr(text)).color(if online { t.text_dim } else { t.accent })); + } + } + ui.add_space(10.0); + if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false) + .on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file")) + .clicked() + { + let _ = app.run("dialog.allMetadata", json!({})); + } + }); +} + +/// The camera card at the top of Info: camera, lens, size · file size and format, then the +/// capture settings. +fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { + let t = Tokens::get(ui.ctx()); + let m = &p.meta; + egui::Frame::NONE.fill(t.canvas).corner_radius(6.0).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + let dim = |s: &str| egui::RichText::new(s.to_string()).size(12.0).color(t.text_label); + if !m.camera.is_empty() { + ui.label(dim(&m.camera)); + } + if !m.lens.is_empty() { + ui.add(egui::Label::new(dim(&m.lens)).truncate()); + } + ui.horizontal(|ui| { + ui.label(dim(&format!("{} × {} · {}", p.width, p.height, human_size(p.file_size)))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::Frame::NONE.fill(t.chrome).corner_radius(3.0).inner_margin(egui::Margin::symmetric(5, 1)).show(ui, |ui| { + ui.label(egui::RichText::new(p.format.to_uppercase()).size(10.5).color(t.text_label)); + }); + }); + }); + let rows = [ + ("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))), + ("Shutter speed", (!m.shutter.is_empty()).then(|| crate::i18n::tr_format!("{} sec", m.shutter))), + ("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))), + ("ISO", m.iso.map(|i| i.to_string())), + ]; + if rows.iter().any(|r| r.1.is_some()) { + ui.add_space(6.0); + egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| { + for (k, v) in rows { + ui.label(dim(crate::i18n::tr(k))); + ui.label(dim(&v.unwrap_or_else(|| "—".into()))); + ui.end_row(); + } + }); + } + // where the camera focused, and how (View ▸ Focus Point draws it): only when known; the + // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits + if let Some(text) = focus_text(m.focus.as_ref()) { + ui.add_space(3.0); + let r = ui.horizontal_wrapped(|ui| { + ui.label(dim(crate::i18n::tr("Focus"))); + ui.add_space(12.0); + ui.label(egui::RichText::new(text).size(12.0).color(t.text)); + }); + register(ui, "info:focus", r.response.rect); + } + }); +} + +/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye +/// Tracking"), or `None` when the photo has no focus point. +pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option { + let f = f?; + let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); + Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) +} + +fn human_size(bytes: u64) -> String { + match bytes { + b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), + b if b >= 1 << 10 => format!("{} KB", b >> 10), + b => format!("{b} B"), + } +} + +/// What a metadata field shows while the selected photos' values differ (Selected Photos). +const MIXED: &str = "Mixed"; + +/// An Info panel field's edit under way, kept in egui memory between frames. A field saves when +/// its text is not the text the edit started from, so a field left as it was saves nothing, even +/// when what the panel shows changed during the edit. It saves to the photos it edited when the +/// edit started (the selected photos, or Target Photo's photo alone), also when the selection +/// changed since. +#[derive(Clone)] +struct FieldEdit { + text: String, + start: String, + /// The photos the field edited when the edit started (`photo.setMeta` `ids`). + ids: Vec, + /// The field said Mixed when the edit started. + mixed: bool, + /// Something was typed or deleted since. + typed: bool, +} + +impl FieldEdit { + fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self { + let ids = targets.iter().map(|id| id.0).collect(); + FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false } + } + + /// A Mixed field that was typed in and left empty: it clears the field on every selected photo. + fn clears(&self) -> bool { + self.mixed && self.typed + } +} + +/// The Info panel's text fields, by their `photo.setMeta` key. +const META_TEXT_KEYS: [&str; 12] = [ + "title", + "caption", + "altText", + "extendedDescription", + "copyright", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "city", + "state", + "country", +]; + +/// The value of one of [`META_TEXT_KEYS`]. +fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> { + Some(match key { + "title" => &m.title, + "caption" => &m.caption, + "altText" => &m.alt_text, + "extendedDescription" => &m.extended_description, + "copyright" => &m.copyright, + "usageTerms" => &m.usage_terms, + "copyrightUrl" => &m.copyright_url, + "creator" => &m.creator, + "location" => &m.location, + "city" => &m.city, + "state" => &m.state, + "country" => &m.country, + _ => return None, + }) +} + +/// The metadata the Info panel's fields show: the active photo `p`'s, or with `for_selected` +/// (Selected Photos) the active photo's when it is one of the `selected` photos, else the first +/// selected photo's. +pub(crate) fn shown_meta( + catalog: &lightcraft_catalog::Catalog, + p: &lightcraft_catalog::Photo, + selected: &[PhotoId], + for_selected: bool, +) -> lightcraft_catalog::Meta { + if !for_selected || selected.contains(&p.id) { + return p.meta.clone(); + } + selected.iter().find_map(|&id| catalog.photo(id)).map_or_else(|| p.meta.clone(), |q| q.meta.clone()) +} + +/// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of +/// the photos `ids` has a value other than `shown`'s, the metadata the panel draws. +pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> { + let mut mixed = Vec::new(); + for m in ids.iter().filter_map(|&id| catalog.photo(id)).map(|p| &p.meta) { + for key in META_TEXT_KEYS { + if !mixed.contains(&key) && meta_text(m, key) != meta_text(shown, key) { + mixed.push(key); + } + } + if !mixed.contains(&"copyrightStatus") && m.copyright_status != shown.copyright_status { + mixed.push("copyrightStatus"); + } + if !mixed.contains(&"gps") && m.gps != shown.gps { + mixed.push("gps"); + } + if mixed.len() == META_TEXT_KEYS.len() + 2 { + break; + } + } + mixed +} + +/// Target Photo / Selected Photos: whose metadata the fields below show while several photos +/// are selected (`view.infoSelectedPhotos`). +fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr("Show Metadata For")).size(11.5).color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + let on = app.ui.settings.info_selected_photos; + for (id, label, selected) in [("infoFor-target", "Target Photo", false), ("infoFor-selected", "Selected Photos", true)] { + if text_button(ui, id, label, on == selected).clicked() && on != selected { + let _ = app.run("view.infoSelectedPhotos", json!({"on": selected})); + } + } + }); + ui.add_space(6.0); +} + +/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the +/// selected photos' statuses differ, and any status picked is given to all of them. A status +/// picked goes to the photos `targets`. +fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool, targets: &[PhotoId]) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); + let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() }); + let r = crate::widgets::combo_box("info-copyright-status").selected_text(shown).show_ui(ui, |ui| { + for st in lightcraft_catalog::CopyrightStatus::ALL { + let on = !mixed && st == current; + if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on { + let ids: Vec = targets.iter().map(|id| id.0).collect(); + let _ = app.run("photo.setMeta", json!({"ids": ids, "copyrightStatus": st.id()})); + } + } + }); + crate::widgets::name_combo(&r.response, crate::i18n::tr("Copyright Status")); + register(ui, "field:copyrightStatus", r.response.rect); + ui.add_space(6.0); +} + +/// A labeled metadata text field: the typed text lives in egui memory while focused and is +/// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values +/// differ, so the field starts empty and says so, and is saved only when it was typed in (left +/// empty, it clears the field on every selected photo). The edit is saved to the photos `targets`. +#[allow(clippy::too_many_arguments)] +fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool, targets: &[PhotoId]) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); + let id = egui::Id::unique(("info-field", key)); + let value = if mixed { "" } else { value }; + let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, targets)); + let widget = format!("field:{key}"); + let field = if lines > 1 { + crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines) + } else { + crate::text_field::TextField::singleline(&widget, &mut edit.text) + }; + let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field }; + let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); + edit.typed |= edit.text != edit.start; + // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) + if r.editing { + ui.data_mut(|d| d.insert_temp(id, edit.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + // Return (one line) or leaving saves; Esc gives the edit up + if r.committed() && (edit.text != edit.start || edit.clears()) { + let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text})); + } + ui.add_space(6.0); +} diff --git a/crates/ui-egui/src/panels/right/keywords.rs b/crates/ui-egui/src/panels/right/keywords.rs new file mode 100644 index 000000000..6d7d5868b --- /dev/null +++ b/crates/ui-egui/src/panels/right/keywords.rs @@ -0,0 +1,330 @@ +use super::*; + +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) + if app.session.catalog.photo(id).is_none() { + return; + } + let top = ui.cursor().top(); + if fold_header(app, ui, "keywording", "Keywords") { + keywording(app, ui); + } + hold_list_in_place(ui, top); + crate::panels::keyword_list::show(app, ui); +} + +/// The Keywording box: the field that adds keywords, the selection's keywords, the keyword set, +/// the painter and the suggestions. +fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + let kid = egui::Id::unique("kw-input"); + let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); + // the keywords every selected photo has: those only some have are still offered and suggested + let selection = app.session.selected(); + let have: Vec = + app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); + // while the box has the cursor it offers the library's keywords for what is being typed, + // right under itself; it stays for a click on one of them, which takes the cursor away + let field = crate::text_field::id("field:keyword"); + let oid = egui::Id::unique("kw-offers"); + let pass = ui.ctx().cumulative_pass_nr(); + // (remembered from the last frame: the text the highlight was for and the keyword it was + // on, where the offers were drawn, and whether the box was in sight) + let (typed_then, at_then, drawn, drawn_pass, hidden): (String, Option, Rect, u64, bool) = + ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0, false)); + let last_frame = drawn_pass.checked_add(1) == Some(pass); + let clicking = last_frame + && ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p))); + let typed = crate::panels::keywording::typing(&text).to_string(); + // (none while the box is scrolled out of the panel: they would float over what is there) + let offers = ((ui.memory(|m| m.has_focus(field)) || clicking) && !(last_frame && hidden)) + .then(|| app.caches.completions(&app.session.catalog, &have, &typed)) + .filter(|o| !o.keywords.is_empty() || o.create.is_some()); + // ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes + // back to none: Return then gives what was typed, as without offers); Return gives the + // highlighted one. The highlight is on a keyword, not a line: it stays with it when the + // library changes under it, and goes when the keyword does. With Shift or Alt the keys + // are the text's own. + let mut at = at_then.filter(|k| typed_then == typed && offers.as_ref().is_some_and(|o| o.keywords.contains(k))); + let mut picked = None; + if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) { + let pressed = |ui: &mut egui::Ui, key: egui::Key| { + ui.input_mut(|i| { + let plain = + |e: &egui::Event| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.is_none()); + let found = i.events.iter().any(plain); + i.events.retain(|e| !plain(e)); + found + }) + }; + let line = at.as_ref().and_then(|k| o.keywords.iter().position(|x| x == k)); + if pressed(ui, egui::Key::ArrowDown) { + let next = line.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1)); + at = o.keywords.get(next).cloned(); + } else if pressed(ui, egui::Key::ArrowUp) { + at = line.and_then(|a| a.checked_sub(1)).and_then(|a| o.keywords.get(a).cloned()); + } + if let Some(k) = &at + && pressed(ui, egui::Key::Enter) + { + picked = Some(crate::panels::keywording::Picked::Keyword(k.clone())); + } + } + // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) + // (asked for on the frame before this one only: a box that wasn't drawn then, its panel + // switched away, doesn't claim the cursor whenever it is next drawn) + let bid = egui::Id::unique("kw-begin"); + let begin = ui.data_mut(|d| d.remove_temp::(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); + // Add Keywords (⌘K) asks for the cursor too, and for the box to be scrolled into sight + let asked = app.ui.focus_keywords.take().is_some_and(|at| pass <= at.saturating_add(crate::state::FOCUS_KEYWORDS_PASSES)); + let r = crate::text_field::TextField::singleline("field:keyword", &mut text) + .hint(crate::i18n::tr("Add keyword")) + .width(f32::INFINITY) + .begin_edit(begin || asked) + .show(ui); + if asked { + r.response.scroll_to_me(None); + } + let in_sight = ui.clip_rect().contains(r.response.rect.center()); + let mut drawn = Rect::NOTHING; + // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) + if !in_sight { + picked = None; + } + if offers.is_none() { + // (not over the offers) + r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); + } + if let Some(o) = offers.as_ref().filter(|_| in_sight) { + let (clicked, rect) = crate::panels::keywording::offers_below(ui, r.response.rect, o, at.as_deref()); + drawn = rect; + picked = picked.or(clicked); + } + ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass, !in_sight))); + // Return gives the keywords to the selected photos; Esc gives back what was there before + // this edit (the shared field does) + let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty(); + // a new name goes inside the default parent (Put New Keywords Inside This Keyword) + let as_typed = |app: &LightcraftApp, text: &str| -> Vec { + text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() + }; + let adding: Option> = match picked { + // an offer: it, and the keywords typed before the last comma + Some(crate::panels::keywording::Picked::Keyword(k)) => { + let mut kws = crate::panels::keywording::typed_before(&app.session.catalog, &text); + kws.push(k); + Some(kws) + } + Some(crate::panels::keywording::Picked::Typed) => Some(as_typed(app, &text)), + None if returned => Some(as_typed(app, &text)), + None => None, + }; + if let Some(kws) = adding { + // (refused, the text stays to be put right, and the refusal is said) + match app.run("photo.setMeta", json!({"addKeywords": kws})) { + Ok(_) => { + text.clear(); + // an offer taken leaves the cursor in the box, for the next keyword + if !returned { + ui.data_mut(|d| d.insert_temp(bid, pass)); + } + } + Err(e) => app.toast_error(ui.ctx(), e), + } + } + ui.data_mut(|d| d.insert_temp(kid, text)); + ui.add_space(8.0); + crate::panels::keywording::view_switch(app, ui); + ui.add_space(4.0); + crate::panels::keywording::scope(app, ui, &selection); + if app.ui.keywording_view == crate::state::KeywordingView::Keywords { + crate::panels::keywording::chip_row(app, ui, &selection); + } else { + crate::panels::keywording::names_row(app, ui); + } + ui.add_space(10.0); + keyword_set(app, ui); + ui.add_space(8.0); + // the painter: click photos in the grid to give them (or take away) a keyword + ui.horizontal(|ui| { + let pid = egui::Id::unique("kw-painter"); + let painting = app.ui.keyword_painter.clone(); + let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default()); + // (fixed while painting: Stop first) + let field = ui + .add_enabled_ui(painting.is_none(), |ui| { + crate::text_field::TextField::singleline("field:keywordPainter", &mut k) + .hint(crate::i18n::tr("Keyword to paint")) + .width(130.0) + .show(ui) + }) + .inner; + ui.data_mut(|d| d.insert_temp(pid, k.clone())); + let label = if painting.is_some() { "Stop" } else { "Paint" }; + let returned = field.ending == Some(crate::text_field::Ending::Return) && painting.is_none() && !k.trim().is_empty(); + if text_button(ui, "keywordPaint", label, painting.is_some()) + .on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops")) + .clicked() + || returned + { + let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }})); + } + }); + ui.add_space(10.0); + // suggestions: keywords used together with the selection's keywords, else the most used + // ones (what matches the text being typed is offered under the box itself) + let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone(); + if !suggestions.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); + // grouped: the keyword's own name, its whole path in the tooltip + let grouped = app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped; + let names = if grouped { crate::panels::keywording::suggestion_names(&suggestions) } else { Vec::new() }; + ui.horizontal_wrapped(|ui| { + if grouped { + ui.spacing_mut().item_spacing = vec2(6.0, 4.0); + } + for (i, k) in suggestions.into_iter().enumerate() { + let whole = k.replace('|', " › "); + let name = names.get(i).unwrap_or(&whole); + let r = ui.add(egui::Button::new(egui::RichText::new(format!("+ {name}")).color(t.text_label)).corner_radius(10.0)); + let r = if grouped { r.on_hover_text(&whole) } else { r }; + register(ui, format!("kwSuggest:{k}"), r.rect); + if r.clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); + } + } + }); + } + }); +} + +/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at +/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it +/// a long tree slides away from under the pointer by a row of chips each time. It holds only while +/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and +/// while the change does not come from the box itself (the pointer on it, or typing in its field). +fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { + let height = ui.cursor().top() - top; + let key = egui::Id::unique("kw-box-height"); + let pass = ui.ctx().cumulative_pass_nr(); + // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) + let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); + ui.data_mut(|d| d.insert_temp(key, (height, pass))); + let Some(before) = before else { return }; + let grew = height - before; + if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { + return; + } + // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) + let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); + let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); + let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); + if on_box || typing { + return; + } + ui.data_mut(|d| d.insert_temp(egui::Id::unique(HOLD), (grew, pass))); + // (drawn again at once at the new offset, so the list never shows a frame out of place) + ui.ctx().request_discard("the Keyword List holds its place"); +} + +/// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the +/// selected photos; "Save as Set…" keeps the current nine under a name. +fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { + // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do + let selection = app.session.selected(); + let chips = app.caches.keyword_chips(&app.session.catalog, &selection); + let t = Tokens::get(ui.ctx()); + let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); + let current = sets["current"].as_str().unwrap_or_default().to_string(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); + let combo = crate::widgets::combo_box("kw-set") + .selected_text(crate::i18n::builtin_label(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) + .show_ui(ui, |ui| { + for set in sets["sets"].as_array().into_iter().flatten() { + let name = set["name"].as_str().unwrap_or_default(); + if ui + .selectable_label(name == current, crate::i18n::builtin_label(name, name == lightcraft_engine::cmd::keywords::RECENT)) + .clicked() + { + let _ = app.run("keyword.useSet", json!({"name": name})); + } + } + ui.separator(); + // Edit Set…: the current nine, slot by slot (named, Recent Keywords becomes a set) + let edit = ui.button(crate::i18n::tr("Edit Set…")); + register(ui, "keywordSetMenu:edit", edit.rect); + if edit.clicked() { + let mut slots: Vec = + sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); + slots.resize(9, String::new()); + let named = current != lightcraft_engine::cmd::keywords::RECENT; + app.ui.dialog = Some(crate::state::Dialog::KeywordSet { + replaces: named.then(|| current.clone()), + name: if named { current.clone() } else { String::new() }, + slots, + as_new: false, + }); + } + if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: "Save Keyword Set".into(), + hint: "Set name".into(), + value: String::new(), + command: "keyword.saveSet".into(), + params: json!({}), + key: "name".into(), + }); + } + if current != lightcraft_engine::cmd::keywords::RECENT + && ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked() + { + let _ = app.run("keyword.deleteSet", json!({"name": current})); + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Keyword Set")); + register(ui, "keywordSetCombo", combo.response.rect); + }); + let kws: Vec = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); + if kws.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("Keywords you add appear here; ⌥1–⌥9 apply them.")).color(t.text_dim)); + return; + } + let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0); + egui::Grid::new("kw-set-grid").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { + for (i, k) in kws.iter().enumerate() { + // an empty slot: an idle button, so the others keep their ⌥ keys + if k.is_empty() { + let r = ui.add_enabled_ui(false, |ui| ui.add_sized([bw, 22.0], egui::Button::new(""))).inner; + register(ui, format!("kwSetEmpty:{}", i + 1), r.rect); + if i % 3 == 2 { + ui.end_row(); + } + continue; + } + let chip = chips.iter().find(|c| lightcraft_catalog::keywords::same(&c.path, &lightcraft_catalog::keywords::clean(k))); + let on = chip.is_some_and(|c| c.on_all()); + let some = chip.is_some() && !on; + let short = format!("{}{}", k.rsplit('|').next().unwrap_or(k), if some { " *" } else { "" }); + let r = ui + .add_sized( + [bw, 22.0], + egui::Button::new(egui::RichText::new(short).size(11.5).color(if on { t.text } else { t.text_label })).selected(on).truncate(), + ) + .on_hover_text(format!("{} — ⌥{}", k.replace('|', " › "), i + 1)); + register(ui, format!("kwSet:{}", i + 1), r.rect); + if on { + register(ui, format!("kwSetOn:{}", i + 1), r.rect); + } else if some { + register(ui, format!("kwSetSome:{}", i + 1), r.rect); + } + if r.clicked() { + let _ = app.run("keyword.toggleFromSet", json!({"index": i + 1})); + } + if i % 3 == 2 { + ui.end_row(); + } + } + }); +} diff --git a/crates/ui-egui/src/panels/right/tests.rs b/crates/ui-egui/src/panels/right/tests.rs new file mode 100644 index 000000000..1f1d60d60 --- /dev/null +++ b/crates/ui-egui/src/panels/right/tests.rs @@ -0,0 +1,32 @@ +// Feature: the History panel greys out the steps after the cursor +#[test] +fn history_rows_after_the_cursor_are_greyed() { + use super::{HistoryRow, history_row}; + let rows: Vec<_> = (0..4).map(|i| history_row(i, Some(1))).collect(); + assert_eq!(rows, [HistoryRow::Done, HistoryRow::Current, HistoryRow::Undone, HistoryRow::Undone]); + assert_eq!(history_row(3, Some(3)), HistoryRow::Current, "at the latest step nothing is greyed"); + assert_eq!(history_row(0, None), HistoryRow::Done); +} + +// Feature: the aspect button tells what is locked +#[test] +fn aspect_label_names_presets_in_either_orientation() { + assert_eq!(super::aspect_label(None, Some(1.5)), "Free"); + assert_eq!(super::aspect_label(Some((1600, 900)), None), "16 × 9"); + assert_eq!(super::aspect_label(Some((900, 1600)), None), "16 × 9", "portrait 16:9"); + assert_eq!(super::aspect_label(Some((400, 500)), None), "4 × 5 / 8 × 10"); +} + +#[test] +fn aspect_label_prefers_original_over_a_coinciding_preset_and_never_shows_pixel_sizes() { + assert_eq!(super::aspect_label(Some((300, 200)), Some(1.5)), "Original"); + assert_eq!(super::aspect_label(Some((600_000, 400_000)), Some(1.5)), "Original"); + assert_eq!(super::aspect_label(Some((137, 100)), Some(1.5)), "1.37 : 1"); + assert_eq!(super::aspect_label(Some((0, 5)), None), "Free"); +} + +#[test] +fn short_times() { + assert_eq!(super::short_time("2026-09-30T12:00:05"), "Sep 30, 2026, 12:00 PM"); + assert_eq!(super::short_time("2026-01-02"), "Jan 2, 2026"); +} diff --git a/crates/ui-egui/src/panels/second.rs b/crates/ui-egui/src/panels/second.rs index 79aa31d1d..cbdaa2d73 100644 --- a/crates/ui-egui/src/panels/second.rs +++ b/crates/ui-egui/src/panels/second.rs @@ -6,24 +6,31 @@ use egui::{Color32, Rect, pos2, vec2}; use crate::LightcraftApp; use crate::render::Slot; +/// Show the optional second window: an immediate viewport titled "LightCraft — Second Window" +/// whose body draws the active photo fitted. Closing a real window turns the option off; +/// embedded web and headless hosts get a floating window instead. pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if !app.ui.second_window { return; } let vid = egui::ViewportId::from_hash_of("lightcraft-second-window"); let builder = egui::ViewportBuilder::default().with_title("LightCraft — Second Window").with_inner_size([960.0, 640.0]); - ctx.show_viewport_immediate(vid, builder, |ctx, class| { + ctx.show_viewport_immediate(vid, builder, |ui, class| { + // 0.37 passes the viewport's root `Ui` (not a `Context`). Do not enter `root_ui` from it. // (without native windows — web, headless — egui wraps this in a floating window) - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(ctx, |ui| body(app, ui)); - if class != egui::ViewportClass::EmbeddedWindow && ctx.input(|i| i.viewport().close_requested()) { + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(ui, |ui| body(app, ui)); + if class != egui::ViewportClass::EmbeddedWindow && ui.ctx().input(|i| i.viewport().close_requested()) { app.ui.second_window = false; } }); } +/// Draw the second window's content: register `view:secondWindow`, request a loupe render sized +/// for the window (each edge clamped to 64 through the texture-side limit) and paint the active +/// photo fitted, or "No photo selected". fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) { let area = ui.available_rect_before_wrap(); - crate::widgets::register(ui.ctx(), "view:secondWindow", area); + crate::widgets::register(ui, "view:secondWindow", area); ui.allocate_rect(area, egui::Sense::hover()); let Some(id) = app.session.active() else { ui.painter().text( diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs deleted file mode 100644 index ef9ebf85d..000000000 --- a/crates/ui-egui/src/panels/settings.rs +++ /dev/null @@ -1,1653 +0,0 @@ -//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Slideshow, Faces, -//! AI Denoise. -//! -//! Changes apply immediately (no OK/Cancel). Where they are stored: -//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, -//! preview size, filmstrip/grid badges, last library, monitor profile, whether Import Lightroom -//! Catalog… asks where a catalog goes) and Auto Advance live in -//! the UI state, -//! saved by the host in its config folder (`ui.json`); -//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow -//! playback) go through the `library.preferences` / `library.xmpPreferences` / -//! `slideshow.playback` commands into the library's `prefs.json`, -//! so they travel with the library. - -use egui::RichText; -use serde_json::{Value, json}; - -use crate::LightcraftApp; -use crate::state::{GridBadges, PREVIEW_LIMITS, StartupView}; -use crate::theme::Tokens; -use crate::widgets::register; - -/// (id, label) of the tabs, in order. -pub const TABS: &[(&str, &str)] = &[ - ("general", "General"), - ("import", "Import"), - ("performance", "Performance"), - ("display", "Display"), - ("interface", "Interface"), - ("slideshow", "Slideshow"), - ("faces", "Faces"), - ("denoise", "AI Denoise"), -]; - -/// Thumbnail cache sizes offered (MB). -const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192]; - -const LABEL_W: f32 = 150.0; - -/// The page of `tab` (the tab bar in [`window`] switches `tab`). -pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) { - let t = Tokens::get(ui.ctx()); - match tab { - "import" => import_tab(app, ui, &t), - "performance" => performance_tab(app, ui, &t), - "display" => display_tab(app, ui, &t), - "interface" => interface_tab(app, ui, &t), - "slideshow" => slideshow_tab(app, ui, &t, true), - "faces" => super::faces::settings_tab(app, ui, &t), - "denoise" => super::denoise::settings_tab(app, ui, &t), - _ => general_tab(app, ui, &t), - } -} - -/// The widest the Settings window's content gets, and the tallest its pages get: the window is -/// one size on every tab, so it stays where it is as the tabs are gone through. -const PAGE_SIZE: egui::Vec2 = egui::vec2(560.0, 640.0); - -/// The least room in which a page scrolls by itself. In a window with less (far below the -/// desktop minimum), the tabs and the page scroll together in the dialog's own scroll area. -const PAGE_MIN_HEIGHT: f32 = 120.0; - -/// The share of the page's room the pinned slide preview of the Slideshow tab takes, when the page -/// has more room than it needs to keep [`PAGE_MIN_HEIGHT`] for itself. -const PINNED_PREVIEW_SHARE: f32 = 0.4; - -/// What the preview's heading, the one-line hint under the slide, its Photo / Intro / Ending -/// choice and the rule under it take around the slide (at the default spacing, about 82 pt). -const PREVIEW_CHROME: f32 = 82.0; - -/// The Settings window's content: the tab bar, which stays put, above the page of `tab` -/// ([`body`]), which scrolls when it is taller than the room for it, with a scroll handle that -/// shows without the pointer on it. `max_height` is the room the app window leaves for both: the -/// two together never take more, so the scroll area every dialog is drawn in has nothing to -/// scroll (but for a window with less than [`PAGE_MIN_HEIGHT`] for the page, where that area -/// scrolls the two together). Every page gets the same room whatever it holds (but for the pinned -/// preview of Slideshow), and each keeps a scroll position of its own. Nothing here is wider than the window from 640 pt of app window -/// up: a row's controls wrap ([`row`]) and paths are shortened ([`path_label`]). -/// On Slideshow the slide preview is pinned between the tab bar and the page (where the room -/// allows), and comes off that page's room. -pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_height: f32) { - ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, PAGE_SIZE.x)); - let top = ui.cursor().top(); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); - for (id, label) in TABS { - if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { - *tab = id.to_string(); - } - } - }); - ui.separator(); - // (a point less than the room left, so rounding never makes the dialog's own scroll area scroll) - let room = max_height - (ui.cursor().top() - top) - 1.0; - if room < PAGE_MIN_HEIGHT { - body(app, ui, tab.as_str()); - register(ui.ctx(), "settings:page", ui.min_rect()); - return; - } - let mut height = room.min(PAGE_SIZE.y); - // The slide preview stays above the page on Slideshow: hovering a template in the Template - // Browser previews it there, so it has to stay in view however far down the templates are - // scrolled to. With its heading and choice it takes [`PINNED_PREVIEW_SHARE`] of the room - // (at least the least tall slide); where that leaves less than [`PAGE_MIN_HEIGHT`] for the - // page, it scrolls with the page instead (as in the short window above). - let mut pinned = false; - if tab == "slideshow" { - let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(super::slide_layout::PREVIEW_MIN_H); - if height - (slide_h + PREVIEW_CHROME) >= PAGE_MIN_HEIGHT { - let before = ui.cursor().top(); - super::slide_layout::preview(app, ui, &Tokens::get(ui.ctx()), slide_h); - ui.add_space(2.0); - ui.separator(); - height = (height - (ui.cursor().top() - before)).max(PAGE_MIN_HEIGHT); - pinned = true; - } - } - // A page that goes on below shows its scroll handle all the time: without it, a page a line - // or two too long looks cut off, not scrollable. - ui.spacing_mut().scroll.dormant_handle_opacity = 0.6; - let page = egui::ScrollArea::vertical() - .id_salt(("settings-page", tab.as_str())) - .max_height(height) - .min_scrolled_height(height) - .auto_shrink([false, false]) - .show(ui, |ui| { - ui.add_space(2.0); - if pinned { - slideshow_tab(app, ui, &Tokens::get(ui.ctx()), false); - } else { - body(app, ui, tab.as_str()); - } - // (what the page took: wider than the room for it would mean a control is cut off) - register(ui.ctx(), "settings:page", ui.min_rect()); - }); - register(ui.ctx(), "settings:pageRoom", page.inner_rect); -} - -/// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after -/// either kind of separator, so that its end stays (`…/Pictures/Library`, -/// `…\Pictures\Library`): the end says which folder it is. A last folder too long by itself -/// loses its end ([`crate::widgets::elide_head`]). -fn elide_path(path: &str, max: f32, width: impl Fn(&str) -> f32) -> String { - if width(path) <= max { - return path.to_string(); - } - // (a separator at the very end is not where a name starts: `…/` alone would say nothing) - let end = path.trim_end_matches(['/', '\\']).len(); - let mut rest = path; - while let Some(i) = rest.find(['/', '\\']).filter(|i| path.len() - rest.len() + i + 1 < end) { - let separator = if rest.get(i..).is_some_and(|r| r.starts_with('\\')) { '\\' } else { '/' }; - rest = rest.get(i + 1..).unwrap_or(""); - let shorter = format!("…{separator}{rest}"); - if width(&shorter) <= max { - return shorter; - } - } - let name = rest.trim_end_matches(['/', '\\']); - // (nothing but separators: never an empty label) - if name.is_empty() { "…".to_string() } else { crate::widgets::elide_head(name, max, width) } -} - -/// A folder or file path on one line in the room left in its row (`text:{id}`): shortened from -/// the front when it is too long ([`elide_path`]), with all of it in a tooltip then. `after` is -/// the width of what follows it on the line (a button, a note) and its spacing: the path leaves -/// that room, so those never wrap onto a second line. -fn path_label(ui: &mut egui::Ui, id: &str, path: &str, font: egui::FontId, color: egui::Color32, after: f32) { - let shown = - elide_path(path, (ui.available_width() - after).max(0.0), |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x); - // (`truncate` is the last resort, for a room narrower than "…") - let r = ui.add(egui::Label::new(RichText::new(&shown).font(font).color(color)).truncate().show_tooltip_when_elided(false)); - register(ui.ctx(), format!("text:{id}"), r.rect); - if shown != path { - r.on_hover_text(path); - } -} - -/// The width of `text` in the style `style`, and the gap before it: what a label takes in a row. -fn label_width(ui: &egui::Ui, text: &str, style: egui::TextStyle) -> f32 { - ui.painter().layout_no_wrap(text.to_string(), style.resolve(ui.style()), egui::Color32::WHITE).size().x + ui.spacing().item_spacing.x -} - -/// The same for a button, which has padding either side of its text. -fn button_width(ui: &egui::Ui, text: &str) -> f32 { - label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x -} - -pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { - ui.add_space(4.0); - ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); -} - -pub(super) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - ui.horizontal(|ui| { - ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(LABEL_W); - // (a label longer than its column wraps in it; it does not push the controls along) - ui.add(egui::Label::new(RichText::new(crate::i18n::tr(label)).color(t.text_label)).wrap()); - }); - // The controls wrap in the room beside the label, each further line starting under the - // first control, so a row with many choices grows down and never widens the window. - let wrapped = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true); - ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), wrapped, |ui| { - ui.spacing_mut().item_spacing.y = 4.0; - add(ui) - }) - .inner - }) - .inner -} - -pub(super) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { - ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); -} - -/// A checkbox addressable as `check:{id}`; true when toggled. -pub(super) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { - let r = ui.checkbox(value, crate::i18n::tr(label)); - register(ui.ctx(), format!("check:{id}"), r.rect); - r.changed() -} - -/// Mutually exclusive buttons (`button:{id}-{index}`). -pub(super) fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { - let mut changed = false; - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (v, l)) in options.iter().enumerate() { - if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v { - *value = *v; - changed = true; - } - } - changed -} - -// ------------------------------------------------------------------------------------- General - -fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - row(ui, t, crate::i18n::tr("Language"), |ui| { - let languages: Vec<_> = crate::i18n::Locale::for_picker().into_iter().map(|language| (language, language.name())).collect(); - // Thirteen language names do not fit one line in a narrow window: wrap them. - ui.horizontal_wrapped(|ui| { - choices(ui, "settingsLanguage", &languages, &mut app.ui.language); - }); - crate::i18n::set_language(app.ui.language); - }); - heading(ui, t, crate::i18n::tr("Library")); - let location = match &app.session.library { - Some(l) => l.dir.display().to_string(), - None => crate::i18n::tr("In memory — nothing is saved").to_string(), - }; - row(ui, t, crate::i18n::tr("Location"), |ui| { - path_label(ui, "settingsLocation", &location, egui::TextStyle::Body.resolve(ui.style()), t.text, 0.0); - }); - row(ui, t, "", |ui| { - let can = app.services.pick_folder.is_some(); - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…"))); - register(ui.ctx(), "button:settingsOpenLibrary", r.rect); - if r.clicked() { - let _ = app.run("app.openLibrary", json!({})); - } - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…"))); - register(ui.ctx(), "button:settingsNewLibrary", r.rect); - if r.clicked() { - let _ = app.run("app.newLibrary", json!({})); - } - if !can { - hint(ui, t, crate::i18n::tr("not available here")); - } - }); - // where libraries are folders to choose from (not in the browser, which keeps one) - if app.services.pick_folder.is_some() { - use crate::state::LaunchLibrary; - row(ui, t, crate::i18n::tr("At launch"), |ui| { - choices( - ui, - "settingsAtLaunch", - &[(LaunchLibrary::Last, "Open the last library"), (LaunchLibrary::Ask, "Ask me")], - &mut app.ui.settings.at_launch, - ); - }); - hint(ui, t, crate::i18n::tr("Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.")); - } - // File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not - ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label)); - row(ui, t, "", |ui| { - use crate::state::LightroomImport; - choices( - ui, - "settingsLightroomImport", - &[(LightroomImport::Ask, "Ask where to import it"), (LightroomImport::AlwaysMerge, "Always merge into the open library")], - &mut app.ui.settings.lightroom_import, - ); - }); - heading(ui, t, crate::i18n::tr("Startup")); - row(ui, t, crate::i18n::tr("Open in"), |ui| { - choices( - ui, - "settingsStartup", - &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail View")], - &mut app.ui.settings.startup_view, - ); - }); - heading(ui, t, crate::i18n::tr("Culling")); - check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); - check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); - check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); - heading(ui, t, crate::i18n::tr("Dialogs")); - check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it"); - heading(ui, t, crate::i18n::tr("External Editing")); - row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { - crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) - .hint(crate::i18n::tr("Found automatically")) - .width(220.0) - .show(ui); - }); - edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); - hint( - ui, - t, - crate::i18n::tr( - "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.", - ), - ); - row(ui, t, crate::i18n::tr("Other editor"), |ui| { - crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) - .hint(crate::i18n::tr("System default")) - .width(220.0) - .show(ui); - }); - edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); - hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); - hint( - ui, - t, - crate::i18n::tr( - "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).", - ), - ); -} - -/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). -fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) { - use crate::state::{EditFormat, EditSpace}; - row(ui, t, "", |ui| { - choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format); - ui.separator(); - choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth); - }); - row(ui, t, "", |ui| { - choices( - ui, - &format!("{id}Space"), - &[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")], - &mut file.color_space, - ); - }); -} - -// -------------------------------------------------------------------------------------- Import - -/// A preset picker: `None` = `none_label`. Returns the new choice when it changed. -fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option> { - let name = |pid: &str| { - app.session - .presets - .iter() - .find(|p| p.id == pid) - .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) - .unwrap_or_else(|| crate::i18n::tr_format!("{pid} (missing)", pid = pid)) - }; - let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string()); - let mut out = None; - let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| { - if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { - out = Some(None); - } - if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) { - out = Some(Some(pid)); - } - }); - register(ui.ctx(), format!("combo:{id}"), r.response.rect); - out.filter(|v| v.as_deref() != current) -} - -/// Cameras of the raws in the library plus those with a stored default, sorted. -fn cameras(app: &LightcraftApp) -> Vec { - let mut v: Vec = app - .session - .catalog - .photos() - .filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty()) - .map(|p| p.meta.camera.clone()) - .chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone())) - .collect(); - v.sort_by_key(|c| c.to_lowercase()); - v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v -} - -fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - let d = app.session.import_defaults.clone(); - heading(ui, t, crate::i18n::tr("Raw defaults")); - hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library.")); - row(ui, t, crate::i18n::tr("Raw photos"), |ui| { - if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") { - let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}})); - } - }); - let mut per = d.per_camera; - row(ui, t, "", |ui| { - if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") { - let _ = app.run("library.preferences", json!({"import": {"perCamera": per}})); - } - }); - if per { - let cams = cameras(app); - if cams.is_empty() { - hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library.")); - } - egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| { - for (i, cam) in cams.iter().enumerate() { - let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam)); - row(ui, t, cam, |ui| { - // "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default) - const RAW_DEFAULT: &str = "\u{1}raw"; - let current = match entry { - None => Some(RAW_DEFAULT), - Some(e) => e.preset.as_deref(), - }; - let mut pick = None; - let label = match current { - Some(RAW_DEFAULT) => crate::i18n::tr("Same as raw default").to_string(), - None => crate::i18n::tr("LightCraft Default").to_string(), - Some(pid) => app - .session - .presets - .iter() - .find(|p| p.id == pid) - .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) - .unwrap_or_else(|| pid.to_string()), - }; - let id = format!("settingsCamera-{i}"); - let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { - if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { - pick = Some(json!({"camera": cam, "remove": true})); - } - if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { - pick = Some(json!({"camera": cam, "preset": null})); - } - if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) { - pick = Some(json!({"camera": cam, "preset": pid})); - } - }); - register(ui.ctx(), format!("combo:{id}"), r.response.rect); - if let Some(c) = pick { - let _ = app.run("library.preferences", json!({"camera": c})); - } - }); - } - }); - } - heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)")); - row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| { - if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") { - let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); - } - }); - heading(ui, t, crate::i18n::tr("Metadata")); - hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it.")); - for (key, label, hint_text, value) in - [("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())] - { - row(ui, t, label, |ui| { - let id = egui::Id::new(("settingsMeta", key)); - let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0)); - register(ui.ctx(), format!("field:settings.{key}"), r.rect); - if r.lost_focus() && text.trim() != value { - let _ = app.run("library.preferences", json!({"import": {key: text.trim()}})); - } - if r.has_focus() { - ui.data_mut(|m| m.insert_temp(id, text)); - } else { - ui.data_mut(|m| m.remove::(id)); - } - }); - } - if !app.session.metadata_presets.is_empty() { - row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { - let cur = d.metadata_preset.clone(); - let mut pick = None; - let r = crate::widgets::combo_box("settingsMetaPreset") - .width(240.0) - .selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into())) - .show_ui(ui, |ui| { - if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() { - pick = Some(String::new()); - } - for m in &app.session.metadata_presets { - if ui.selectable_label(cur.as_deref() == Some(m.name.as_str()), &m.name).clicked() { - pick = Some(m.name.clone()); - } - } - }); - register(ui.ctx(), "combo:settingsMetaPreset", r.response.rect); - if let Some(n) = pick { - let _ = app.run("library.preferences", json!({"import": {"metadataPreset": n}})); - } - }); - } - heading(ui, t, crate::i18n::tr("XMP sidecars")); - let mut xmp = app.session.xmp; - if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { - let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write})); - } - row(ui, t, crate::i18n::tr("Sidecar names"), |ui| { - use lightcraft_engine::sidecar::SidecarNaming as N; - let mut n = xmp.naming; - if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) { - let naming = if n == N::Full { "full" } else { "stem" }; - let _ = app.run("library.xmpPreferences", json!({"naming": naming})); - } - }); - if !cfg!(target_arch = "wasm32") { - heading(ui, t, crate::i18n::tr("Dropped Files")); - check(ui, "settings.importDropped", &mut app.ui.settings.import_dropped_directly, "Import dropped files without review"); - heading(ui, t, crate::i18n::tr("Cameras and Cards")); - let mut on_card = d.show_on_card; - if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { - let _ = app.run("library.preferences", json!({"import": {"showOnCard": on_card}})); - } - heading(ui, t, crate::i18n::tr("Auto Import")); - hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); - row(ui, t, crate::i18n::tr("Watched folder"), |ui| { - match &d.auto_folder { - Some(folder) => path_label( - ui, - "settingsAutoFolder", - folder, - egui::TextStyle::Body.resolve(ui.style()), - t.text, - button_width(ui, crate::i18n::tr("Choose…")) + button_width(ui, crate::i18n::tr("Turn Off")), - ), - None => drop(ui.label(RichText::new(crate::i18n::tr("Off")).color(t.text))), - } - let can = app.services.pick_folder.is_some(); - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…"))); - register(ui.ctx(), "button:settingsAutoFolder", r.rect); - if r.clicked() - && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) - && let Err(e) = app.run("library.autoImport", json!({"folder": f})) - { - app.toast(ui.ctx(), e); - } - if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() { - let _ = app.run("library.autoImport", json!({"folder": null})); - } - }); - if d.auto_folder.is_some() { - row(ui, t, "", |ui| { - let mut copy = d.auto_copy; - if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() { - let _ = app.run("library.autoImport", json!({"copy": copy})); - } - }); - row(ui, t, crate::i18n::tr("Album"), |ui| { - let id = egui::Id::new("auto-album"); - let mut name = ui.data(|m| m.get_temp::(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0)); - if r.lost_focus() { - let _ = app.run("library.autoImport", json!({"album": name.trim()})); - } - ui.data_mut(|m| m.insert_temp(id, name)); - }); - } - } - if app.session.library.is_none() { - hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits.")); - } -} - -// --------------------------------------------------------------------------------- Performance - -fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - use lightcraft_engine::gpu; - heading(ui, t, crate::i18n::tr("Rendering")); - check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); - let status = if !gpu::available() { - match gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why), - None => "No usable GPU found: rendering on the CPU".to_string(), - } - } else { - format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) - }; - // One line, the whole text on hover: the reason can be a long driver message (no adapter - // among the backends …), and the GPU state is process-wide, so it may change between frames; - // a hint that wrapped would move every control below it under the pointer. - ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status); - row(ui, t, crate::i18n::tr("Preview size"), |ui| { - let opts: Vec<(u32, String)> = - PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); - let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); - choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_limit); - }); - hint( - ui, - t, - crate::i18n::tr( - "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.", - ), - ); - row(ui, t, crate::i18n::tr("Memory for caches"), |ui| { - let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); - let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; - choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb); - }); - heading(ui, t, crate::i18n::tr("Thumbnail cache")); - let cur = (app.session.cache_bytes() >> 20) as u32; - row(ui, t, crate::i18n::tr("Size limit"), |ui| { - let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")]; - let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 }; - if choices(ui, "settingsCache", &opts, &mut v) { - let _ = app.run("library.preferences", json!({"cacheMb": v})); - } - }); - let used = app.session.media.rendered.disk().map(|d| d.size()); - row(ui, t, crate::i18n::tr("In use"), |ui| { - ui.label( - RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) - .color(t.text), - ); - let r = ui.button(crate::i18n::tr("Clear Cache")); - register(ui.ctx(), "button:settingsClearCache", r.rect); - if r.clicked() { - let _ = app.run("library.clearPreviews", json!({})); - } - }); - heading(ui, t, crate::i18n::tr("Local folders")); - row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| { - let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")]; - let mut v = app.session.forget_local_days; - if choices(ui, "settingsForgetLocal", &opts, &mut v) { - let _ = app.run("library.preferences", json!({"forgetLocalDays": v})); - } - }); - hint( - ui, - t, - crate::i18n::tr( - "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.", - ), - ); - #[cfg(not(target_arch = "wasm32"))] - smart_previews(app, ui, t); -} - -/// Where this library keeps its smart previews (the offline-editing proxies, which can be large): -/// the effective folder, what is in it, and choosing another one. -#[cfg(not(target_arch = "wasm32"))] -fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - // listing a big folder every frame would be slow: refresh every 2 s and after a change - let cache = egui::Id::new("smart-location"); - let now = ui.input(|i| i.time); - let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) { - Some((at, v)) if now - at < 2.0 => v, - _ => { - let v = app.run("library.smartPreviewsLocation", json!({})).unwrap_or(Value::Null); - ui.data_mut(|d| d.insert_temp(cache, (now, v.clone()))); - v - } - }; - if loc.is_null() { - return; - } - heading(ui, t, crate::i18n::tr("Smart previews")); - let path = loc["path"].as_str().unwrap_or_default().to_string(); - let available = loc["available"].as_bool().unwrap_or(true); - let custom = loc["custom"].as_bool().unwrap_or(false); - row(ui, t, crate::i18n::tr("Folder"), |ui| { - path_label( - ui, - "settingsSmartFolder", - &path, - egui::TextStyle::Body.resolve(ui.style()), - if available { t.text } else { t.reject }, - if custom { 0.0 } else { label_width(ui, "(library default)", egui::TextStyle::Body) }, - ); - if !custom { - let r = ui.label(RichText::new("(library default)").color(t.text)); - register(ui.ctx(), "text:settingsSmartDefault", r.rect); - } - }); - let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); - if available { - hint( - ui, - t, - &crate::i18n::tr_format!( - "{count} smart preview{} · {:.1} MB", - if count == 1 { "" } else { "s" }, - bytes as f64 / 1048576.0, - count = count - ), - ); - } else { - hint( - ui, - t, - crate::i18n::tr( - "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.", - ), - ); - } - // what happens to the previews already built when the folder changes - let mode_id = egui::Id::new("smart-existing"); - let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0); - if count > 0 { - row(ui, t, crate::i18n::tr("Existing previews"), |ui| { - if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) { - ui.data_mut(|d| d.insert_temp(mode_id, mode)); - } - }); - } - let existing = ["move", "leave", "discard"][mode.min(2) as usize]; - let mut result = None; - ui.horizontal(|ui| { - ui.add_space(LABEL_W); - if app.services.pick_folder.is_some() { - let r = ui.button(crate::i18n::tr("Choose Folder…")); - register(ui.ctx(), "button:settingsSmartChoose", r.rect); - if r.clicked() - && let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f()) - { - result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing}))); - } - } - let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder"))); - register(ui.ctx(), "button:settingsSmartReset", r.rect); - if r.clicked() { - result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing}))); - } - }); - if let Some(r) = result { - ui.data_mut(|d| d.remove::<(f64, Value)>(cache)); - match r { - Ok(v) => { - let failed = v["failed"].as_array().map_or(0, Vec::len); - let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) { - (_, 0) => "Smart previews folder changed".to_string(), - ("move", n) => format!("Smart previews folder changed; moved {n}"), - ("discard", n) => format!("Smart previews folder changed; deleted {n}"), - _ => "Smart previews folder changed".to_string(), - }; - app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg }); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.")); -} - -// ---------------------------------------------------------------------------------- Interface - -/// The monitor profile (`app.displayProfile`): previews are shown through the display's ICC -/// profile, so wide-gamut and calibrated displays show colours as they are. -fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - heading(ui, t, crate::i18n::tr("Monitor profile")); - let current = app.renderer.display().cloned(); - let screen = current.as_ref().is_some_and(|d| d.path == crate::SCREEN_PROFILE); - row(ui, t, crate::i18n::tr("Profile"), |ui| { - let name = match ¤t { - Some(d) => d.profile.description.clone(), - None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(), - }; - ui.label(RichText::new(name).color(t.text)); - if screen { - hint(ui, t, crate::i18n::tr("this screen's profile")); - } - }); - row(ui, t, "", |ui| { - let can = app.services.pick_display_profile.is_some(); - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose Profile…"))); - register(ui.ctx(), "button:settingsDisplayProfile", r.rect); - if r.clicked() - && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) - { - app.ui.settings.display_profile = path; - } - // with no profile chosen, previews follow the screen's own profile where the host knows it - let none = if app.services.screen_profile.is_some() { crate::i18n::tr("Use Screen Profile") } else { crate::i18n::tr("Use sRGB") }; - let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(none)); - register(ui.ctx(), "button:settingsDisplayProfileNone", r.rect); - if r.clicked() { - app.ui.settings.display_profile.clear(); - } - if !can { - hint(ui, t, crate::i18n::tr("not available here")); - } - }); - if let Some(e) = &app.display_error { - ui.label(RichText::new(e).size(11.0).color(t.reject)); - } - if let Some(d) = ¤t { - let info = d.describe(); - if !screen { - row(ui, t, crate::i18n::tr("File"), |ui| { - path_label(ui, "settingsDisplayFile", &d.path, egui::FontId::proportional(11.0), t.text_dim, 0.0); - }); - } - row(ui, t, crate::i18n::tr("Type"), |ui| { - let kind = match d.profile.kind { - lightcraft_engine::display::DisplayKind::MatrixTrc => crate::i18n::tr("Matrix/TRC"), - lightcraft_engine::display::DisplayKind::Lut => crate::i18n::tr("LUT-based"), - }; - ui.label(RichText::new(kind).color(t.text)); - }); - row(ui, t, crate::i18n::tr("Primaries (x, y)"), |ui| { - let p = &info["primaries"]; - let xy = |(k, l): (&str, &str)| format!("{l} {:.3}, {:.3}", p[k][0].as_f64().unwrap_or(0.0), p[k][1].as_f64().unwrap_or(0.0)); - let text = [("red", "R"), ("green", "G"), ("blue", "B")].map(xy).join(" "); - ui.label(RichText::new(text).size(11.0).color(t.text_dim)); - }); - } - hint( - ui, - t, - crate::i18n::tr( - "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.", - ), - ); - #[cfg(target_os = "linux")] - hint(ui, t, crate::i18n::tr("Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.")); -} - -fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - let system = app.system_theme(ui.ctx()); - appearance(ui, t, &mut app.ui.settings, system); - heading(ui, t, crate::i18n::tr("Filmstrip")); - check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); - check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); - heading(ui, t, crate::i18n::tr("Grid")); - row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| { - choices( - ui, - "settingsGridBadges", - &[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")], - &mut app.ui.settings.grid_badges, - ); - }); - check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); - heading(ui, t, crate::i18n::tr("Keywords")); - row(ui, t, crate::i18n::tr("Keywords of the selection"), |ui| { - use crate::state::KeywordChips as K; - choices(ui, "settingsKeywordChips", &[(K::Paths, "Whole names"), (K::Grouped, "Grouped by parent")], &mut app.ui.settings.keyword_chips); - }); - hint( - ui, - t, - crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), - ); - heading(ui, t, crate::i18n::tr("Detail View")); - check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); - row(ui, t, crate::i18n::tr("Info overlay"), |ui| { - use crate::state::InfoOverlay as I; - choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); - }); - hint(ui, t, crate::i18n::tr("View → View Options chooses the lines Info 1 and Info 2 show.")); -} - -/// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and -/// which, and the three lines each of Info 1 and Info 2 shows (`view.viewOptions`). Changes apply -/// as they are made. -pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; - heading(ui, t, crate::i18n::tr("Loupe View")); - let mut show = app.ui.info_overlay != I::Off; - if check(ui, "viewOptions.showInfo", &mut show, "Show Info Overlay") { - app.ui.info_overlay = if show { I::Basic } else { I::Off }; - } - ui.add_enabled_ui(show, |ui| { - ui.horizontal(|ui| { - choices(ui, "viewOptionsInfo", &[(I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); - }); - }); - for (n, title, defaults) in [(1, "Loupe Info 1", LoupeInfo::INFO1), (2, "Loupe Info 2", LoupeInfo::INFO2)] { - heading(ui, t, crate::i18n::tr(title)); - let lines = if n == 1 { &mut app.ui.settings.loupe_info.info1 } else { &mut app.ui.settings.loupe_info.info2 }; - for (i, line) in lines.iter_mut().enumerate() { - let id = format!("viewOptionsInfo{n}Line{}", i + 1); - let r = crate::widgets::combo_box(&id).width(220.0).selected_text(crate::i18n::tr(line.label())).show_ui(ui, |ui| { - for f in F::ALL { - let key = serde_json::to_value(f).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - let r = ui.selectable_label(line == f, crate::i18n::tr(f.label())); - register(ui.ctx(), format!("button:{id}-{key}"), r.rect); - if r.clicked() { - *line = *f; - } - } - }); - register(ui.ctx(), format!("combo:{id}"), r.response.rect); - } - let r = ui.add_enabled(*lines != defaults, egui::Button::new(crate::i18n::tr("Use Defaults"))); - register(ui.ctx(), format!("button:viewOptionsDefaults{n}"), r.rect); - if r.clicked() { - *lines = defaults; - } - } -} - -// ----------------------------------------------------------------------------------- Slideshow - -/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory -/// (the slideshow reads it); letting go saves it through `slideshow.playback`. -fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive) { - let mut v = value; - let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1)); - register(ui.ctx(), format!("slider:{id}"), r.rect); - if r.changed() - && let Ok(next) = app.session.slideshow.merged(&json!({ key: v })) - { - app.session.slideshow = next; - } - if r.drag_stopped() || (r.changed() && !r.dragged()) { - let _ = app.run("slideshow.playback", json!({ key: v })); - } -} - -/// The Slideshow tab: the slide preview, then the settings, in one column the width of the page -/// (the page scrolls; a column beside the preview would be wider than the window). Without -/// `with_preview` the preview is left out: [`window`] has pinned it above the page. -fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, with_preview: bool) { - if with_preview { - super::slide_layout::preview(app, ui, t, f32::INFINITY); - ui.add_space(6.0); - } - super::slide_templates::section(app, ui, t); - ui.add_space(6.0); - playback_section(app, ui, t); - ui.add_space(6.0); - super::slide_layout::sections(app, ui, t); - ui.add_space(6.0); - super::slide_overlays::sections(app, ui, t); - ui.add_space(6.0); - super::slide_titles::sections(app, ui, t); - ui.add_space(6.0); - export_section(app, ui, t); -} - -/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). -fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - heading(ui, t, crate::i18n::tr("Export")); - let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); - ui.horizontal_wrapped(|ui| { - for (id, label, format) in [ - ("slideshowExportPdf", "Export PDF…", "pdf"), - ("slideshowExportJpeg", "Export JPEG…", "jpeg"), - ("slideshowExportVideo", "Export Video…", "video"), - ] { - let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label))); - register(ui.ctx(), format!("button:{id}"), r.rect); - if r.clicked() { - let _ = app.run("dialog.slideshowExport", json!({"format": format})); - } - } - }); - hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack.")); -} - -fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; - heading(ui, t, crate::i18n::tr("Playback")); - let s = app.session.slideshow.clone(); - row(ui, t, crate::i18n::tr("Slide duration"), |ui| { - duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) - }); - row(ui, t, crate::i18n::tr("Fade duration"), |ui| { - duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration)) - }); - hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts.")); - row(ui, t, crate::i18n::tr("Fade"), |ui| { - #[derive(Clone, Copy, PartialEq)] - enum Fade { - Cross, - Black, - White, - Color, - } - let mut f = match s.fade_color { - None => Fade::Cross, - Some([0, 0, 0]) => Fade::Black, - Some([255, 255, 255]) => Fade::White, - Some(_) => Fade::Color, - }; - let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")]; - if choices(ui, "settingsSlideshowFade", &opts, &mut f) { - let c = match f { - Fade::Cross => Value::Null, - Fade::Black => json!("#000000"), - Fade::White => json!("#ffffff"), - Fade::Color => json!([64, 64, 64]), - }; - let _ = app.run("slideshow.playback", json!({"fadeColor": c})); - } - if f == Fade::Color - && let Some(mut c) = s.fade_color - { - let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); - register(ui.ctx(), "button:slideshowFadeColor", r.rect); - if r.changed() { - let _ = app.run("slideshow.playback", json!({"fadeColor": c})); - } - } - }); - let mut v = s.random_order; - if check(ui, "settings.slideshowRandom", &mut v, "Random order") { - let _ = app.run("slideshow.playback", json!({"randomOrder": v})); - } - let mut v = s.repeat; - if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") { - let _ = app.run("slideshow.playback", json!({"repeat": v})); - } - let mut v = s.prepare_previews; - if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") { - let _ = app.run("slideshow.playback", json!({"preparePreviews": v})); - } - hint( - ui, - t, - crate::i18n::tr( - "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).", - ), - ); - hint( - ui, - t, - crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."), - ); -} - -/// 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 -/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). -fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { - use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; - heading(ui, t, crate::i18n::tr("Appearance")); - ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); - ui.horizontal_wrapped(|ui| { - choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); - }); - ui.add_space(4.0); - let light_active = s.theme(system) == s.light_theme.kind(); - let available = ui.available_width(); - 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); - 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); - }; - if available < 440.0 { - ui.vertical(|ui| cards(ui, available)); - } else { - ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); - } - ui.add_space(2.0); -} - -/// 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, -) { - egui::Frame::new() - .fill(t.inset) - .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) - .corner_radius(6) - .inner_margin(8) - .show(ui, |ui| { - ui.vertical(|ui| { - let inner = (width - 18.0).clamp(120.0, 240.0); - ui.set_width(inner); - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); - if active { - ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); - } - }); - let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); - theme_preview(ui, shown, inner); - for (v, kind) in options { - let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); - let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); - ui.horizontal(|ui| { - let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); - register(ui.ctx(), format!("radio:theme-{}", kind.id()), r.rect); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let favorite = favorites.iter().any(|id| id == kind.id()); - let r = ui - .add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false)) - .on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" })); - r.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::Button, - true, - format!( - "{}: {}", - crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }), - crate::i18n::tr(kind.label()) - ), - ) - }); - register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect); - if favorite || hovered { - crate::rating::paint_star(ui.painter(), r.rect.shrink(4.0), favorite, if favorite { t.accent } else { t.text_dim }); - } - if r.clicked() { - if favorite { - favorites.retain(|id| id != kind.id()); - } else { - favorites.push(kind.id().into()); - } - } - }); - }); - } - }); - }); -} - -/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the -/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and -/// the bottom bar. -fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { - use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; - let p = Tokens::for_kind(kind); - let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); - let painter = ui.painter_at(rect); - let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); - painter.rect_filled(rect, 3.0, p.canvas); - // top bar: sidebar and back icons, the centred search field, icons on the right - let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); - painter.rect_filled(top, 0.0, p.chrome); - for x in [6.0, 14.0, 20.0] { - painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); - } - let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); - painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); - bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); - for i in 0..4 { - painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); - } - // bottom bar: view buttons, rating stars, a button - let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); - painter.rect_filled(bottom, 0.0, p.chrome); - for x in [6.0, 13.0, 20.0] { - painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); - } - let stars_x = bottom.center().x - 30.0; - for i in 0..5 { - painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); - } - let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); - painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); - // tool strip on the right edge, the first tool (Edit) active - let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); - painter.rect_filled(strip, 0.0, p.chrome); - painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); - for i in 0..5 { - let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); - if i == 0 { - painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); - } - painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); - } - // Edit panel: histogram, then labelled sliders - let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); - painter.rect_filled(panel, 0.0, p.chrome); - painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); - let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); - painter.rect_filled(hist, 1.0, p.inset); - let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; - let mut pts = vec![hist.left_bottom()]; - for (i, h) in curve.iter().enumerate() { - pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); - } - pts.push(hist.right_bottom()); - // the area under the curve, a column per stretch between two points - for pair in pts.windows(2) { - if let [a, b] = pair { - let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); - painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); - } - } - painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); - let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; - let mut y = hist.bottom() + 7.0; - bar(y, panel.left() + 5.0, 14.0, p.text); - y += 6.0; - for v in sliders { - if y + 7.0 > panel.bottom() { - break; - } - bar(y, panel.left() + 5.0, 12.0, p.text_label); - bar(y, panel.right() - 10.0, 5.0, p.text_dim); - let track_y = y + 5.0; - painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); - painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); - y += 10.0; - } - // the photo (its own colours: a photo looks the same in every theme) above the filmstrip - let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); - let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); - let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); - painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); - let horizon = photo.top() + photo.height() * 0.62; - painter.add(egui::Shape::convex_polygon( - vec![ - pos2(photo.left(), horizon), - pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), - pos2(photo.left() + photo.width() * 0.55, horizon), - ], - Color32::from_rgb(0x4a, 0x55, 0x66), - Stroke::NONE, - )); - painter.add(egui::Shape::convex_polygon( - vec![ - pos2(photo.left() + photo.width() * 0.35, horizon), - pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), - pos2(photo.right(), horizon), - ], - Color32::from_rgb(0x5a, 0x63, 0x72), - Stroke::NONE, - )); - painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); - painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); - for i in 0..5 { - let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); - if cell.right() > film.right() - 2.0 { - break; - } - let selected = i == 1; - painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); - let thumb = cell.shrink(2.5); - painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); - if selected { - painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); - } - } -} - -// ------------------------------------------------------------------------------- Open Library… - -/// Switching libraries is refused while an import (or the folder scan before one) is running: its -/// remaining batches would be committed into the new library (issue #1509). -pub(crate) fn can_switch_library(app: &LightcraftApp) -> Result<(), String> { - if crate::lightroom_import::is_running(app) { - return Err("wait for Lightroom catalog import to finish before switching libraries".into()); - } - if app.import.is_some() || app.scan.is_some() { - return Err("wait for the import to finish before switching libraries".into()); - } - Ok(()) -} - -/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, -/// or a folder chosen in a dialog. Remembered as the library to open at launch. -/// -/// A Lightroom Classic catalog (its folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` -/// itself, see [`lightcraft_engine::lightroom_folder`]) is not opened: the current library stays -/// and a dialog offers Import Lightroom Catalog… instead; returns `{opened: false, -/// lightroomCatalog: <.lrcat path or null>, path}`. -pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { - can_switch_library(app)?; - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library")); - match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { - crate::pick::Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Ok(Value::Null), - }, - crate::pick::Picked::Later => return Ok(Value::Null), - crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), - } - } - }; - // A Lightroom catalog folder (or part of one) is not a library: before closing the library - // open now, offer to import it instead (issue #891). The engine refuses it too. - if let Some(place) = lightcraft_engine::lightroom_folder::lightroom_catalog_at(std::path::Path::new(&path)) { - let catalog = place.catalog.map(|c| c.to_string_lossy().into_owned()); - let err = lightcraft_engine::EngineError::LightroomCatalog { path: path.clone().into(), catalog: catalog.clone().map(Into::into) }; - // the library problem window (no library could be opened) shows the error instead - if app.library_problem.is_some() { - return Err(err.to_string()); - } - app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() }); - return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path})); - } - // a remembered library that is gone: say so (and forget it) instead of creating an empty one there - if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() { - app.ui.settings.recent_libraries.retain(|r| r != &path); - return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library")); - } - switch_library(app, &path) -} - -/// Close the current library and open the one at `path`. If it can't be opened (locked by -/// another program, unreadable, a file), the error is returned and the current library stays open: -/// the target is checked before anything is closed, and if opening it still fails the previous -/// library is opened again (both errors are reported if that fails too). -fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { - let dir = app.session.check_library(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; - let path = dir.to_string_lossy().into_owned(); - // the library being left goes in the list too (also when it was opened at launch) - let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); - app.session.close_library().map_err(|e| e.to_string())?; - let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); - app.lightroom_last = None; - app.lightroom_result = None; - if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) { - app.ui.dialog = None; - } - app.renderer.forget_all(); - if let Err(err) = opened { - let Some(prev) = previous_dir else { return Err(err) }; - return match app.session.open_library(&prev, false) { - Ok(_) => Err(err), - Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())), - }; - } - // Photo IDs are local to a catalog: pins cannot follow them into another library (a refused - // or failed switch reopens the same catalog, so its pins stay). - app.ui.reference = None; - app.ui.compare = None; - if app.ui.view == crate::state::ViewMode::Reference { - app.ui.view = crate::state::ViewMode::PhotoGrid; - } - app.ui.settings.library_path = path.clone(); - if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { - remember_library(&mut app.ui.settings.recent_libraries, &prev); - } - remember_library(&mut app.ui.settings.recent_libraries, &path); - Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) -} - -/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`]. -pub fn remember_library(recent: &mut Vec, path: &str) { - recent.retain(|r| r != path); - recent.insert(0, path.to_string()); - recent.truncate(crate::state::RECENT_LIBRARIES); -} - -/// A library's name as people see it: its folder's name. -pub fn library_name(dir: &std::path::Path) -> String { - dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) -} - -/// The open library's name (`None`: in memory, nothing is saved). -pub fn open_library_name(app: &LightcraftApp) -> Option { - app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir)) -} - -/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library -/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it, -/// `path` itself, or a new folder named after the default library. The folder must not hold a -/// library already, nor anything else. -pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result { - can_switch_library(app)?; - let name = match p.get("name") { - None | Some(Value::Null) => None, - Some(Value::String(s)) => Some(valid_library_name(s)?), - Some(_) => return Err("app.newLibrary: name must be a string".into()), - }; - // from the menu: ask for the name first (default prefilled), then for the location - if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() { - crate::panels::dialogs::prompt( - app, - "New Library", - "Library name", - lightcraft_engine::library::DEFAULT_NAME, - "app.newLibrary", - json!({}), - "name", - ); - return Ok(Value::Null); - } - let (location, picked) = match p.get("path").and_then(Value::as_str) { - Some(x) => (std::path::PathBuf::from(x), false), - None => { - let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It")); - match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { - crate::pick::Picked::Now(v) => match v.into_iter().next() { - Some(x) => (std::path::PathBuf::from(x), true), - None => return Ok(Value::Null), - }, - crate::pick::Picked::Later => return Ok(Value::Null), - crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), - } - } - }; - let dir = match name { - Some(n) => location.join(n), - // a chosen location gets a new folder in it, named after the default library - None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME), - None => location, - }; - if lightcraft_engine::library::is_library(&dir) { - return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display())); - } - if dir.is_file() { - return Err(format!("{}: is a file; a library is a folder", dir.display())); - } - if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { - return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); - } - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?; - switch_library(app, &dir.to_string_lossy()) -} - -/// Characters a folder name can't hold on Windows (and `/`, which no system allows). -const RESERVED_NAME_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; - -fn valid_library_name(s: &str) -> Result<&str, String> { - let n = s.trim(); - // Windows drops trailing dots and spaces from a folder name: refuse them rather than - // creating a folder whose name differs from the one asked for (spaces around the name - // are trimmed first) - let bad = n.is_empty() || n == "." || n == ".." || n.ends_with(['.', ' ']) || n.contains(RESERVED_NAME_CHARS) || n.chars().any(char::is_control); - if bad { - return Err(format!( - "app.newLibrary: {s:?} can't be a library name (a folder name without < > : \" / \\ | ? * and not ending in a dot or space)" - )); - } - Ok(n) -} - -/// `base` inside `location`, or `base 2`, `base 3`… if that is taken. -pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { - let first = location.join(base); - if !first.exists() { - return first; - } - (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) -} - -/// `app.recentLibraries {remove?: path, clear?: bool}`: the recently opened libraries, newest -/// first, as `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last -/// background check found (`false`: moved, on a disconnected drive, not a folder or no longer a -/// library; `null`: not checked yet, or the check couldn't tell — a folder that can't be listed, an -/// unreadable catalog, a lookup or check that failed; `checking` says whether a check is running): the command never -/// looks at a folder itself, as one on an offline network share can take seconds to answer. -/// `lastOpened`: ISO 8601 time, `null` when not known. `remove` forgets that -/// one entry (an error if the list doesn't hold it) and `clear` forgets them all; neither touches -/// anything on disk (issue #1290). -pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { - match p.get("remove") { - None | Some(Value::Null) => {} - Some(Value::String(path)) => { - if !forget_library(&mut app.ui.settings, path) { - return Err(format!("app.recentLibraries: {path:?} is not in the recent libraries")); - } - app.recent_checks.forget(path); - } - Some(_) => return Err("app.recentLibraries: remove must be a library path (a string)".into()), - } - if p.get("clear").and_then(Value::as_bool) == Some(true) { - for path in std::mem::take(&mut app.ui.settings.recent_libraries) { - app.recent_checks.forget(&path); - } - app.ui.settings.recent_opened.clear(); - } - let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let (settings, checks) = (&app.ui.settings, &app.recent_checks); - let list: Vec = settings - .recent_libraries - .iter() - .map(|r| { - let d = std::path::Path::new(r); - let exists = checks.exists(r); - json!({"path": r, "name": library_name(d), "exists": exists, "checking": checks.is_checking(r), "open": open.as_deref() == Some(d), "lastOpened": settings.recent_opened.get(r)}) - }) - .collect(); - Ok(Value::Array(list)) -} - -/// Forget `path` in File ▸ Open Recent Library (and when it was opened); the library itself is -/// left where it is. False if the list didn't hold it. -pub fn forget_library(settings: &mut crate::state::AppSettings, path: &str) -> bool { - let before = settings.recent_libraries.len(); - settings.recent_libraries.retain(|r| r != path); - settings.recent_opened.remove(path); - settings.recent_libraries.len() != before -} - -// ---------------------------------------------------------------------------- Display profile - -/// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile -/// (`""` or `null`: none, the profile of the screen showing the window where the host knows it -/// (macOS), else the display is treated as sRGB) and keep it as the setting; no `path`: report -/// only. A file that can't be used is an error and changes nothing. Returns the profile in use: -/// `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`, with -/// `path: null, screen: true` for the screen's profile. -pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { - let path = match p.get("path") { - None => None, - Some(Value::Null) => Some(String::new()), - Some(Value::String(s)) => Some(s.trim().to_string()), - Some(_) => return Err("app.displayProfile: path must be a string or null".into()), - }; - if let Some(path) = path { - let d = lightcraft_engine::display::Display::load_opt(&path).map_err(|e| format!("app.displayProfile: {e}"))?; - app.renderer.set_display(d); - app.ui.settings.display_profile = path.clone(); - app.display_applied = Some(path); - app.display_error = None; - app.screen_profile = None; - app.screen_profile_read = None; - if app.ui.settings.display_profile.is_empty() { - app.apply_screen_profile(); - } - } - Ok(match app.renderer.display() { - Some(d) if d.path == crate::SCREEN_PROFILE => { - let mut v = d.describe(); - v["path"] = Value::Null; - v["screen"] = json!(true); - v - } - Some(d) => d.describe(), - None => json!({"path": null, "description": "sRGB (no display profile)"}), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn app_with_import() -> LightcraftApp { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); - app.run("photo.setReference", json!({})).unwrap(); - app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); - app - } - - #[test] - fn open_library_is_refused_while_an_import_runs() { - let mut app = app_with_import(); - let pin = app.ui.reference; - let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert!(app.import.is_some()); - assert_eq!(app.ui.reference, pin, "a refused Open Library keeps the reference pin"); - } - - #[test] - fn new_library_is_refused_while_an_import_runs() { - let mut app = app_with_import(); - let pin = app.ui.reference; - let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert!(app.import.is_some()); - assert_eq!(app.ui.reference, pin, "a refused New Library keeps the reference pin"); - } - - /// The menu items that switch libraries are greyed out meanwhile, like during a Lightroom import. - #[test] - fn library_switching_menu_items_are_disabled_while_an_import_runs() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.services.pick_folder = Some(Box::new(|| None)); - assert!(crate::menus::ui_enabled(&app, "app.openLibrary")); - assert!(crate::menus::ui_enabled(&app, "app.newLibrary")); - app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); - assert!(!crate::menus::ui_enabled(&app, "app.openLibrary")); - assert!(!crate::menus::ui_enabled(&app, "app.newLibrary")); - } - - /// The folder scan before an import's review counts too: it ends in an import into the open library. - #[test] - fn open_library_is_refused_while_an_import_scan_runs() { - let dir = std::env::temp_dir().join(format!("lc-ui-switchscan-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); - app.run("photo.setReference", json!({})).unwrap(); - let pin = app.ui.reference; - crate::import::open(&mut app, vec![dir.to_string_lossy().into_owned()]).unwrap(); - assert!(app.scan.is_some()); - let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert_eq!(app.ui.reference, pin, "a refused Open Library during a scan keeps the reference pin"); - let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert_eq!(app.ui.reference, pin, "a refused New Library during a scan keeps the reference pin"); - assert!(app.scan.is_some()); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod path_tests { - use super::elide_path; - - /// Width = characters, so a limit of 20 is "20 characters". - fn fit(path: &str, max: usize) -> String { - elide_path(path, max as f32, |s| s.chars().count() as f32) - } - - #[test] - fn a_path_that_fits_is_left_alone() { - assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); - assert_eq!(fit("", 5), ""); - } - - #[test] - fn leading_folders_go_first_and_the_end_stays() { - assert_eq!(fit("/Users/me/Pictures/Library", 20), "…/Pictures/Library"); - assert_eq!(fit("/Users/me/Pictures/Library", 10), "…/Library"); - } - - /// Windows paths: `Path::display` gives backslashes there. - #[test] - fn a_path_with_backslashes_keeps_its_end_too() { - assert_eq!(fit(r"C:\Users\me\Pictures\Library", 20), r"…\Pictures\Library"); - assert_eq!(fit(r"C:\Users\me/Pictures\Library", 10), r"…\Library"); - } - - #[test] - fn a_last_folder_too_long_by_itself_loses_its_end() { - assert_eq!(fit("/Users/me/a-very-long-folder-name", 8), "a-very-…"); - assert_eq!(fit("no-separator-at-all", 8), "no-sepa…"); - assert_eq!(fit("/Users/Фотографии-из-отпуска", 8), "Фотогра…"); - } - - #[test] - fn a_separator_at_the_end_is_not_where_the_name_starts() { - assert_eq!(fit("/Users/me/Pictures/Library/", 11), "…/Library/"); - assert_eq!(fit("/Users/me/a-very-long-folder-name/", 8), "a-very-…"); - assert_eq!(fit(r"C:\Users\me\Library\", 11), r"…\Library\"); - assert_eq!(fit("///", 2), "…"); - assert_eq!(fit(r"\\server\share\folder", 15), r"…\share\folder"); - } -} diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs index a0f7ba09f..cfe0ce6e6 100644 --- a/crates/ui-egui/src/panels/slideshow.rs +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -1,6 +1,6 @@ //! The slideshow (View ▸ Slideshow, ⌥⌘↩): the photos in view, full screen, played with the //! library's Playback settings (`slideshow.playback`: slide and fade durations, fade colour, -//! random order, repeat, preparing previews in advance). +//! random order, repeat, preparing previews in advance, manual slideshow). //! //! The timeline is [`lightcraft_engine::slideshow::Playback`] (no egui, tested on its own); this //! module renders the slides, draws the frame it asks for and the playback controls. @@ -53,7 +53,7 @@ pub struct Run { } /// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?, -/// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's +/// repeat?, preparePreviews?, manual?}`: play the photos in view from the selected one with the library's /// playback settings; the keys given change them for this run only (`interval` = slideDuration, /// `seed` fixes the random order). The intro and ending screens that are on (`slideshow.titles`) /// play before the first photo and after the last one. With no photos in view there is nothing @@ -127,7 +127,9 @@ pub fn state(play: &Playback, now: f64) -> Value { "fading": f.from.is_some(), "progress": f.progress, "waitingFor": play.waiting_for().map(|i| i.photo().map_or_else(|| json!(i.title().map(Title::id)), |p| json!(p.0))), + // (null in a manual slideshow: no slide is ever due) "remaining": play.remaining(now), + "manual": play.settings().manual, }) } @@ -154,7 +156,7 @@ pub fn ready(app: &LightcraftApp, item: Item) -> bool { } /// Advance a running slideshow (every frame, before drawing). -pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { let Some(run) = app.ui.slideshow.as_ref() else { app.renderer.release_slides(&[]); return; @@ -163,7 +165,6 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { end(app); return; } - let now = ctx.input(|i| i.time); let mut play = run.play.clone(); let t = play.tick(now, |id| ready(app, id)); if let Some(run) = app.ui.slideshow.as_mut() { @@ -174,7 +175,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let f = run.play.frame(now); if f.from.is_some() { ctx.request_repaint(); // a fade draws every frame - } else if !run.play.is_paused() { + } else if !run.play.is_paused() && !run.play.settings().manual { ctx.request_repaint_after(std::time::Duration::from_secs_f64(run.play.remaining(now).clamp(0.016, 120.0))); } if run.controls_until > now { @@ -293,7 +294,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // (`ui.pointer` positions are relative to it) app.image_rect = Some(canvas); let Some(run) = app.ui.slideshow.as_ref() else { return }; - let now = ui.input(|i| i.time); + let now = app.tick_now(); let ppp = ui.ctx().pixels_per_point(); let side = super::detail::texture_side(ui.ctx()); let want = wanted(&run.play, now); @@ -367,7 +368,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { draw(frame.current, 1.0); } } - register(ui.ctx(), "slideshow:canvas", canvas); + register(ui, "slideshow:canvas", canvas); controls(app, ui, canvas, now); } diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index e26d1088f..2ba807dcc 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -9,42 +9,16 @@ use crate::theme::Tokens; use crate::widgets::{icon_button, register}; /// The bar doubles as the window's title bar: dragging its empty space moves the window and a -/// double-click zooms or restores it. Registered before the bar's widgets so they win the click. -fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, bar: Rect, search_rect: Rect) { - use crate::titlebar::{Gesture, WindowState, command_for}; - register(ui.ctx(), "region:titlebar", bar); - let handle = egui::Id::new("titlebar-handle"); - let resp = ui.interact(bar, handle, Sense::click_and_drag()); +/// double-click acts as the system setting says. Made before the bar's widgets so they win the click. +fn window_handle(ui: &mut egui::Ui, bar: Rect) { + register(ui, "region:titlebar", bar); + let resp = craft_window::title_bar::empty_area(ui, bar); crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); - // a press that began on a button or the search field is theirs: dragging off it must not move the window - // (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one) - let ctx = ui.ctx().clone(); - let memo = egui::Id::new("titlebar-press-on-widget"); - if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) { - let hovered = ctx.interaction_snapshot(|s| s.hovered.clone()); - let on_widget = - hovered.iter().any(|id| *id != handle && ctx.read_response(*id).is_some_and(|r| r.sense.senses_click() || r.sense.senses_drag())); - ctx.data_mut(|d| d.insert_temp(memo, on_widget)); - } - let on_widget = ctx.data(|d| d.get_temp::(memo)).unwrap_or(false); - // The search field is drawn after this handle. Its hit-test response may not exist - // yet when the press is recorded; the press origin still identifies text selection. - let on_search = ui.input(|i| i.pointer.press_origin().is_some_and(|p| search_rect.contains(p))); - let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) && !on_search { - Some(Gesture::DoubleClicked) - } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget && !on_search { - Some(Gesture::DragStarted) - } else { - None - }; - if let Some(g) = gesture { - let window = WindowState { maximized: ui.input(|i| i.viewport().maximized).unwrap_or(false), fullscreen: app.window_is_fullscreen }; - if let Some(cmd) = command_for(g, window) { - ui.ctx().send_viewport_cmd(cmd); - } - } } +/// Draw the top bar: the titlebar drag area, the in-window menus when there is no native menu +/// bar, sidebar and back/forward buttons, the centred search field, the filter button with its +/// active count, and the right-side action icons. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // room for the traffic lights; in full screen they are gone, and the sidebar button sits as far @@ -75,7 +49,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let search_left = bar.center().x - sw / 2.0; let sr = Rect::from_center_size(pos2(bar.center().x, full.center().y), vec2(sw, 28.0)); if app.integrated_titlebar { - window_handle(app, ui, bar, sr); + window_handle(ui, bar); } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; @@ -133,9 +107,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // 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::new("filter-btn"), Sense::click()); + let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); - register(ui.ctx(), "icon:filter", fr); + register(ui, "icon:filter", fr); let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( ui.painter(), @@ -192,9 +166,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("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::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + 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.ctx(), format!("icon:{id}"), r); + register(ui, format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { t.caution @@ -208,7 +182,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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.ctx(), "indicator:unsaved", r); + register(ui, "indicator:unsaved", r); } // a refusal says why (Import while a Lightroom catalog is coming in) if resp.clicked() @@ -223,16 +197,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + 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.ctx(), "icon:appearance", r); + 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.ctx(), "menu:appearanceSettings", settings.rect); + register(ui, "menu:appearanceSettings", settings.rect); if settings.clicked() { let _ = app.run("app.settings", json!({"tab": "interface"})); ui.close(); @@ -246,7 +220,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } 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.ctx(), format!("menu:themeFavorite-{}", kind.id()), r.rect); + register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect); if r.clicked() { let _ = app.run("view.theme", json!({"theme": kind.id()})); ui.close(); diff --git a/crates/ui-egui/src/panels/unnamed.rs b/crates/ui-egui/src/panels/unnamed.rs index e1ef879e2..7f42437b2 100644 --- a/crates/ui-egui/src/panels/unnamed.rs +++ b/crates/ui-egui/src/panels/unnamed.rs @@ -144,12 +144,12 @@ pub fn naming_bar(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let label = if n == 1 { "Name face".to_string() } else { format!("Name {n} faces") }; let b = ui.add_enabled(!chosen.is_empty(), egui::Button::new(label)); - register(ui.ctx(), "unnamed:name", b.rect); + register(ui, "unnamed:name", b.rect); if b.clicked() { submit = Some(chosen); } let c = ui.button(crate::i18n::tr("Clear")); - register(ui.ctx(), "unnamed:clear", c.rect); + register(ui, "unnamed:clear", c.rect); clear = c.clicked() || field.cancelled(); }); // the people already named, matching what is typed (a click names the faces) @@ -162,7 +162,7 @@ pub fn naming_bar(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(super::people::PAD - 4.0); for p in matching { let b = ui.add(egui::Button::new(RichText::new(p.as_str()).color(t.text_label)).frame(false)); - register(ui.ctx(), format!("unnamed:pick:{p}"), b.rect); + register(ui, format!("unnamed:pick:{p}"), b.rect); if b.clicked() { submit = Some(p.clone()); } @@ -204,7 +204,7 @@ pub fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed if !list.ready && list.faces.is_empty() { let r = Rect::from_min_size(pos2(at.x, at.y + 38.0), vec2(120.0, 22.0)); let b = ui.put(r, egui::Button::new(crate::i18n::tr("Open Settings"))); - register(ui.ctx(), "unnamed:openSettings", b.rect); + register(ui, "unnamed:openSettings", b.rect); if b.clicked() { let _ = app.run("app.settings", json!({"tab": "faces"})); } @@ -212,7 +212,7 @@ pub fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed let r = Rect::from_min_size(pos2(right - 130.0, at.y + 12.0), vec2(130.0, 22.0)); let all = app.ui.unnamed_selected.len() == list.faces.len(); let b = ui.put(r, egui::Button::new(crate::i18n::tr(if all { "Select none" } else { "Select all" }))); - register(ui.ctx(), "unnamed:selectAll", b.rect); + register(ui, "unnamed:selectAll", b.rect); if b.clicked() { app.ui.unnamed_selected.clear(); if !all { @@ -228,14 +228,14 @@ pub fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed, i: usize let t = Tokens::get(ui.ctx()); let key = (f.photo, f.index); let selected = app.ui.unnamed_selected.contains(&key); - let resp = ui.interact(r, egui::Id::new(("unnamed-face", f.photo, f.index)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("unnamed-face", f.photo, f.index)), Sense::click()); crate::widgets::announce_selected(&resp, crate::i18n::tr("Unnamed face"), selected); - register(ui.ctx(), format!("unnamed-face:{}:{}", f.photo, f.index), r); + register(ui, format!("unnamed-face:{}:{}", f.photo, f.index), r); // a suggested name along the bottom: a click accepts it for this face (added after the tile, so it wins there) let strip = Rect::from_min_max(pos2(r.left(), r.bottom() - 20.0), r.right_bottom()); - let accept = f.suggestion.as_ref().map(|_| ui.interact(strip, egui::Id::new(("unnamed-accept", f.photo, f.index)), Sense::click())); + let accept = f.suggestion.as_ref().map(|_| ui.interact(strip, egui::Id::unique(("unnamed-accept", f.photo, f.index)), Sense::click())); if let Some(a) = &accept { - register(ui.ctx(), format!("unnamed-accept:{}:{}", f.photo, f.index), a.rect); + register(ui, format!("unnamed-accept:{}:{}", f.photo, f.index), a.rect); } let hovered = resp.hovered() || accept.as_ref().is_some_and(|a| a.hovered()); let p = ui.painter(); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 3994a950d..26552313e 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -445,641 +445,4 @@ pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), m } #[cfg(test)] -mod tests { - use super::*; - use crate::state::AppSettings; - - const BIG: usize = 16384; - - fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } - } - - fn auto() -> AppSettings { - AppSettings::default() - } - - // Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded - #[test] - fn a_retina_fit_view_needs_no_window() { - let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); - assert_eq!(p.main_edge, 2560, "the preview source level"); - assert_eq!(p.window_edge, None); - } - - // Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp - #[test] - fn a_large_fit_view_gets_a_window() { - let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); - } - - // Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the - // window is cut from the photo's own pixels - #[test] - fn one_to_one_renders_the_canvas_and_a_native_window() { - let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); - } - - // Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow - #[test] - fn beyond_one_to_one_the_window_stays_at_native_resolution() { - let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); - let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); - assert_eq!(q.window_edge, Some(6000)); - } - - // Given a small photo zoomed in, the whole-frame render already holds all its pixels - #[test] - fn a_small_photo_needs_no_window() { - let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); - assert_eq!((p.main_edge, p.window_edge), (1000, None)); - } - - // Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is - // the whole-frame draft that gets cheaper - #[test] - fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { - let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); - assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); - assert_eq!(drag.window_edge, still.window_edge); - } - - // Given no window (a whole-frame diagnostic), the view at rest renders the - // whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the - // windowed path - #[test] - fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft: Option| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; - let still = plan(&auto(), no_window(None)); - let drag = plan(&auto(), no_window(Some(0.6))); - assert_eq!(still.window_edge, None); - assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); - assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); - } - - // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) - #[test] - fn a_drag_shrinks_the_draft_only_on_the_cpu() { - use lightcraft_catalog::PhotoId; - let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); - let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); - } - - // Given a drag that started before the GPU was ready, it keeps its size until it ends: the - // resolution never jumps in the middle of a drag - #[test] - fn a_drag_keeps_its_draft_size_until_it_ends() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None, 0.0), None); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); - // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); - } - - /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. - fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { - let scale = d.scale(drag, true, None, now); - for key in 1..=6 { - d.observe(key, ms, None); - } - d.scale(None, true, None, now); - scale - } - - // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask - // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on - // that photo drafts small; once could be a busy moment. Other controls and photos keep full size - #[test] - fn a_drag_over_budget_twice_drafts_small_the_next_time() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); - d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { - d.observe(key, ms, None); - d.observe(key, ms, None); // a render is timed once, however many frames show it - } - assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None, 0.0), None); - assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); - assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); - d.scale(None, true, None, 2.0); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); - } - - // Given a pair found slow, it drafts small only for a while: then a drag tries full size - // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict - #[test] - fn a_slow_verdict_expires_and_is_retried() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 25.0, 0.0); - timed_drag(&mut d, drag, 25.0, 0.0); - assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); - assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); - let mut at = RETRY_AFTER_S; - let mut wait = RETRY_AFTER_S * 2.0; - for _ in 0..6 { - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); - at += wait; - wait = (wait * 2.0).min(RETRY_MAX_S); - } - assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); - assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); - assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); - } - - // Given a drag only just over budget, it doesn't count against the pair (it would flip between - // the two sizes); one clearly over does - #[test] - fn only_a_drag_clearly_over_budget_counts() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for t in 0..4 { - timed_drag(&mut d, drag, 18.0, f64::from(t)); - } - assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); - d.scale(None, true, None, 4.0); - timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); - timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); - assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); - } - - // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size - #[test] - fn a_busy_moment_doesnt_make_a_slider_draft_small() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 40.0, 0.0); - for t in 1..=3 { - assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); - } - timed_drag(&mut d, drag, 40.0, 4.0); - assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); - } - - // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it - // can't read tries full size - #[test] - fn a_hostile_clock_never_panics() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 25.0, 100.0); - timed_drag(&mut d, drag, 25.0, 100.0); - assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); - d.scale(None, true, None, 50.0); - assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); - d.scale(None, true, None, f64::NAN); - for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { - timed_drag(&mut d, drag, 25.0, now); - } - } - - // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be - // slow), the next drag stays at full size - #[test] - fn a_drag_within_budget_stays_full_size() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None, 0.0); - for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { - d.observe(key, ms, None); - } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - // hostile times are ignored, never a panic - for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { - d.observe(key, ms, None); - } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - } - - // Given renders within budget whose frames (the texture upload) take it over, the drag counts - // as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms - #[test] - fn a_drafts_time_includes_the_frame_that_shows_it() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for _ in 0..2 { - d.scale(drag, true, None, 0.0); - for key in 1..=5 { - d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft - } - d.observe(5, 8.0, Some(14.0)); - d.scale(None, true, None, 0.0); - } - assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); - // the same renders without the host's frame times (headless) stay within budget - let mut headless = DragScale::default(); - headless.scale(drag, true, None, 0.0); - for _ in 0..2 { - timed_drag(&mut headless, drag, 8.0, 0.0); - } - assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); - // a hostile frame time counts as nothing, never a panic - let mut hostile = DragScale::default(); - hostile.scale(drag, true, None, 0.0); - for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { - hostile.observe(key, 8.0, Some(frame)); - } - hostile.scale(None, true, None, 0.0); - assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); - } - - // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: - // clearing them all would bring back a slow full-size drag for every pair - #[test] - fn a_full_slow_set_forgets_only_the_oldest_drag() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - for photo in 0..=VERDICTS_KEPT as u64 { - d.remember_slow(PhotoId(photo), "Exposure"); - } - assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); - d.scale(None, true, None, 0.0); - for photo in [1, VERDICTS_KEPT as u64] { - assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); - d.scale(None, true, None, 0.0); - } - d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); - } - - // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as - // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such - // drags, since one slow drag only makes the pair suspect - #[test] - fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for t in 0..2u32 { - assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); - for key in 1..=6 { - d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); - } - d.scale(None, true, None, f64::from(t)); - } - assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); - } - - // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end; after a second - // such drag, the next starts small - #[test] - fn a_drag_far_over_budget_drafts_small_before_it_ends() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - let mut scales = Vec::new(); - for key in 1..=8 { - d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None, 0.0)); - } - // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame - assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); - assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None, 0.0); - assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); - assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); - } - - // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is - // the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees - #[test] - fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { - let zoomed = sizes(24000.0, 2800.0, 6000); - let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); - let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); - assert!(full.window_edge.is_some(), "{full:?}"); - assert_eq!(full, small); - // at fit without a window, full size stays full size - let fit = sizes(2800.0, 2800.0, 6000); - assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); - } - - // Given a drag at fit at full size, the draft is as sharp as the render at rest - #[test] - fn a_full_size_drag_at_fit_keeps_the_resolution() { - for drawn in [1400.0, 2800.0] { - let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); - assert_eq!(drag, still, "{drawn}"); - } - } - - // Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 - // must not render the photo's every pixel each frame - #[test] - fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { - let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); - assert!(drag.main_edge <= 2816, "{drag:?}"); - } - - // Given the user capped the preview size, the whole-frame render obeys it, and a fit view - // stays on it (no original decoded); a zoomed view still gets its sharp window - #[test] - fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { - let s = AppSettings { preview_limit: 1600, ..Default::default() }; - let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); - assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); - let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); - assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); - } - - // Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not - // (a window is cut to the texture side separately) - #[test] - fn the_texture_side_caps_the_whole_frame_render_only() { - let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); - } - - // Given a picture that is not the frame (an embedded JPEG cropped differently), no window - #[test] - fn a_window_needs_a_picture_with_the_frames_aspect() { - assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); - assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); - } - - // Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: - // it is only the whole-frame draft that gets smaller - #[test] - fn a_drag_at_fit_on_a_large_display_keeps_the_window() { - for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { - let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); - assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); - assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); - } - } - - // Given a mode whose picture can't be a window (a whole-frame diagnostic, or a - // spot read too far for one render), the whole-frame render is as big as the view - #[test] - fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { - let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!((p.main_edge, p.window_edge), (6000, None)); - let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); - assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); - let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); - assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); - let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!(small.main_edge, 2048); - } - - // Given a user who chose a preview size above the preview source level (3840 or 5120 on a big - // display), the whole-frame render is that big - #[test] - fn a_large_preview_limit_is_honoured() { - for limit in [3840u32, 5120] { - let s = AppSettings { preview_limit: limit, ..Default::default() }; - let p = plan(&s, sizes(5120.0, 5120.0, 6000)); - assert_eq!(p.main_edge, limit as usize, "{limit}"); - assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); - } - let s = AppSettings { preview_limit: 2560, ..Default::default() }; - assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); - } - - // Given a medium format photo wider than 8192 px, 1:1 is its own pixels - #[test] - fn a_window_frame_can_be_as_big_as_the_photo() { - let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); - assert_eq!(p.window_edge, Some(11648)); - } - - // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) - #[test] - fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); - assert_eq!(p.window_edge, None); - } - - // Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet - #[test] - fn a_pinch_holds_the_render_sizes() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - assert_ne!(fit, zoomed); - let mut hold = SizeHold::default(); - let p = PhotoId(1); - assert_eq!(hold.apply(p, 0.0, false, fit), fit); - // the pinch runs for a second: every frame would plan something else - for k in 1..60 { - assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}"); - } - // it stopped at t = 59/60: still held for a moment (no flicker between two events)… - assert!(hold.holding(1.1)); - assert_eq!(hold.apply(p, 1.1, false, zoomed), fit); - // …then the view is planned for where it ended - assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01)); - assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed); - assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); - } - - // Given a pinch, the display-fit size holds like the render sizes and is retaken after it - #[test] - fn a_pinch_holds_the_display_fit_size() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - let mut hold = SizeHold::default(); - let p = PhotoId(1); - hold.apply(p, 0.0, false, fit); - assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]); - for k in 1..60 { - let now = k as f64 / 60.0; - hold.apply(p, now, true, zoomed); - assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}"); - } - // stopped at t = 59/60: still held within the hold time… - hold.apply(p, 1.1, false, zoomed); - assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]); - // …then the size it ended at is taken, and kept while nothing moves - hold.apply(p, 1.3, false, zoomed); - assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]); - assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]); - // another photo forgets the held size - hold.apply(p, 1.5, true, zoomed); - hold.display_fit(1.5, true, [2561, 2049]); - hold.apply(PhotoId(2), 1.6, true, zoomed); - assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]); - } - - // Given another photo, the held sizes are forgotten - #[test] - fn another_photo_is_planned_afresh() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - let mut hold = SizeHold::default(); - hold.apply(PhotoId(1), 0.0, false, fit); - assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); - } - - // Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line - #[test] - fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() { - use crate::state::BeforeAfter::*; - let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0)); - let (left, right) = ( - egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)), - egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)), - ); - assert_eq!(window_clip(SideBySide, true, left, canvas, left), left); - assert_eq!(window_clip(SideBySide, false, right, canvas, right), right); - let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0)); - let before = window_clip(Split, true, canvas, canvas, img); - let after = window_clip(Split, false, canvas, canvas, img); - assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); - assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); - assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); - // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle - let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); - assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); - assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); - assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); - assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); - } - - // Given a window asked for while another is rendering and the original isn't decoded yet, it - // waits for the decoded original instead of decoding another copy - #[test] - fn a_window_waits_for_the_original_another_is_decoding() { - assert!(defer_window(true, false)); - assert!(!defer_window(true, true), "the original is in"); - assert!(!defer_window(false, false), "nothing is decoding it"); - } - - // Hostile numbers give a plan, never a panic - #[test] - fn hostile_sizes_give_a_usable_plan() { - for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { - let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); - assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); - } - let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); - assert!(p.main_edge >= 8); - } - - // Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid - #[test] - fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { - let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); - let m = margin_for(48_000); - assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); - assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); - assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); - } - - // Given a small pan, the same window is kept (no re-render); a big pan changes it - #[test] - fn small_pans_keep_the_window() { - let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); - let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap(); - assert_eq!(a, b); - let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap(); - assert_ne!(a, c); - } - - // Given a frame smaller than the margin, or a view at its edge, the window is clamped into it - #[test] - fn the_window_stays_inside_the_frame() { - let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap(); - assert_eq!((w.x, w.y), (0, 0)); - let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap(); - assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); - assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); - } - - // Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity - // and dehaze planes are 0.045 of the frame), up to a cap that bounds the window - #[test] - fn the_margin_grows_with_the_zoom_up_to_a_cap() { - assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); - assert!(margin_for(8000) > margin_for(3000)); - assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); - assert_eq!(margin_for(80_000), MAX_MARGIN); - assert_eq!(margin_for(0), SNAP); - assert!(margin_for(usize::MAX) <= MAX_MARGIN); - for l in [100, 3000, 8000, 48_000, 400_000] { - assert_eq!(margin_for(l) % SNAP, 0); - } - } - - // Given a tile rendered for other settings, another photo or another zoom, it is not drawn - #[test] - fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { - use lightcraft_catalog::PhotoId; - let v = RegionView { - photo: PhotoId(1), - before: false, - key: 7, - full: (6000, 4000), - window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, - settings: 11, - }; - assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); - assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); - assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); - assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); - } - - // Given a GPU that allows 2048 px textures, no window is wider than that - #[test] - fn a_window_fits_the_texture_side() { - for tex in [512, 2048, 4096] { - let span = max_span(tex); - assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}"); - let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap(); - assert!(w.w <= span && w.h <= span, "{tex}: {w:?}"); - } - assert_eq!(max_span(100_000), MAX_SPAN); - assert_eq!(max_span(0), SNAP); - } - - // Given nothing visible, or hostile numbers, there is no window (never a panic) - #[test] - fn nothing_visible_or_hostile_input_is_no_window() { - assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None); - assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None); - assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None); - for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None); - } - let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap(); - assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); - } - - // Given a view of an enormous area, the window is bounded - #[test] - fn a_window_is_bounded() { - let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap(); - assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); - } -} +mod tests; diff --git a/crates/ui-egui/src/region/tests.rs b/crates/ui-egui/src/region/tests.rs new file mode 100644 index 000000000..33cda088c --- /dev/null +++ b/crates/ui-egui/src/region/tests.rs @@ -0,0 +1,630 @@ +use super::*; +use crate::state::AppSettings; + +const BIG: usize = 16384; + +fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } +} + +fn auto() -> AppSettings { + AppSettings::default() +} + +// Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded +#[test] +fn a_retina_fit_view_needs_no_window() { + let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); + assert_eq!(p.main_edge, 2560, "the preview source level"); + assert_eq!(p.window_edge, None); +} + +// Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp +#[test] +fn a_large_fit_view_gets_a_window() { + let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); +} + +// Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the +// window is cut from the photo's own pixels +#[test] +fn one_to_one_renders_the_canvas_and_a_native_window() { + let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); +} + +// Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow +#[test] +fn beyond_one_to_one_the_window_stays_at_native_resolution() { + let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); + let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); + assert_eq!(q.window_edge, Some(6000)); +} + +// Given a small photo zoomed in, the whole-frame render already holds all its pixels +#[test] +fn a_small_photo_needs_no_window() { + let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); + assert_eq!((p.main_edge, p.window_edge), (1000, None)); +} + +// Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is +// the whole-frame draft that gets cheaper +#[test] +fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { + let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); + assert_eq!(drag.window_edge, still.window_edge); +} + +// Given no window (a whole-frame diagnostic), the view at rest renders the +// whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the +// windowed path +#[test] +fn without_a_window_a_drag_drafts_at_the_canvas_size() { + let no_window = |draft: Option| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; + let still = plan(&auto(), no_window(None)); + let drag = plan(&auto(), no_window(Some(0.6))); + assert_eq!(still.window_edge, None); + assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); + assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); +} + +// Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) +#[test] +fn a_drag_shrinks_the_draft_only_on_the_cpu() { + use lightcraft_catalog::PhotoId; + let mut gpu = DragScale::default(); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); + let mut cpu = DragScale::default(); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); +} + +// Given a drag that started before the GPU was ready, it keeps its size until it ends: the +// resolution never jumps in the middle of a drag +#[test] +fn a_drag_keeps_its_draft_size_until_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); + // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); +} + +/// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. +fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale +} + +// Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask +// the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on +// that photo drafts small; once could be a busy moment. Other controls and photos keep full size +#[test] +fn a_drag_over_budget_twice_drafts_small_the_next_time() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let exposure = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); + d.observe(7, 400.0, None); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { + d.observe(key, ms, None); + d.observe(key, ms, None); // a render is timed once, however many frames show it + } + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); +} + +// Given a pair found slow, it drafts small only for a while: then a drag tries full size +// again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict +#[test] +fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); +} + +// Given a drag only just over budget, it doesn't count against the pair (it would flip between +// the two sizes); one clearly over does +#[test] +fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); +} + +// Given one slow drag while the machine was busy, then fast ones, the slider keeps full size +#[test] +fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); +} + +// Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it +// can't read tries full size +#[test] +fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } +} + +// Given full-size drafts within budget (the first one, rebuilding the stage caches, may be +// slow), the next drag stays at full size +#[test] +fn a_drag_within_budget_stays_full_size() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None, 0.0); + for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + // hostile times are ignored, never a panic + for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); +} + +// Given renders within budget whose frames (the texture upload) take it over, the drag counts +// as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms +#[test] +fn a_drafts_time_includes_the_frame_that_shows_it() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); + } + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); + // the same renders without the host's frame times (headless) stay within budget + let mut headless = DragScale::default(); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); + } + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); + // a hostile frame time counts as nothing, never a panic + let mut hostile = DragScale::default(); + hostile.scale(drag, true, None, 0.0); + for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { + hostile.observe(key, 8.0, Some(frame)); + } + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); +} + +// Given more slow drags than are remembered, only the one remembered longest ago is forgotten: +// clearing them all would bring back a slow full-size drag for every pair +#[test] +fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=VERDICTS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); +} + +// Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as +// unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such +// drags, since one slow drag only makes the pair suspect +#[test] +fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); +} + +// Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), +// the drag drops to the small draft after a few of them instead of at its end; after a second +// such drag, the next starts small +#[test] +fn a_drag_far_over_budget_drafts_small_before_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + let mut scales = Vec::new(); + for key in 1..=8 { + d.observe(key, 190.0, None); + scales.push(d.scale(drag, true, None, 0.0)); + } + // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame + assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); + assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); +} + +// Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is +// the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees +#[test] +fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { + let zoomed = sizes(24000.0, 2800.0, 6000); + let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); + let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); + assert!(full.window_edge.is_some(), "{full:?}"); + assert_eq!(full, small); + // at fit without a window, full size stays full size + let fit = sizes(2800.0, 2800.0, 6000); + assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); +} + +// Given a drag at fit at full size, the draft is as sharp as the render at rest +#[test] +fn a_full_size_drag_at_fit_keeps_the_resolution() { + for drawn in [1400.0, 2800.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); + assert_eq!(drag, still, "{drawn}"); + } +} + +// Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 +// must not render the photo's every pixel each frame +#[test] +fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge <= 2816, "{drag:?}"); +} + +// Given the user capped the preview size, the whole-frame render obeys it, and a fit view +// stays on it (no original decoded); a zoomed view still gets its sharp window +#[test] +fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { + let s = AppSettings { preview_limit: 1600, ..Default::default() }; + let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); + assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); + let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); + assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); +} + +// Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not +// (a window is cut to the texture side separately) +#[test] +fn the_texture_side_caps_the_whole_frame_render_only() { + let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); +} + +// Given a picture that is not the frame (an embedded JPEG cropped differently), no window +#[test] +fn a_window_needs_a_picture_with_the_frames_aspect() { + assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); + assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); +} + +// Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: +// it is only the whole-frame draft that gets smaller +#[test] +fn a_drag_at_fit_on_a_large_display_keeps_the_window() { + for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); + assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); + assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); + } +} + +// Given a mode whose picture can't be a window (a whole-frame diagnostic, or a +// spot read too far for one render), the whole-frame render is as big as the view +#[test] +fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { + let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (6000, None)); + let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); + let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); + assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); + let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!(small.main_edge, 2048); +} + +// Given a user who chose a preview size above the preview source level (3840 or 5120 on a big +// display), the whole-frame render is that big +#[test] +fn a_large_preview_limit_is_honoured() { + for limit in [3840u32, 5120] { + let s = AppSettings { preview_limit: limit, ..Default::default() }; + let p = plan(&s, sizes(5120.0, 5120.0, 6000)); + assert_eq!(p.main_edge, limit as usize, "{limit}"); + assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); + } + let s = AppSettings { preview_limit: 2560, ..Default::default() }; + assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); +} + +// Given a medium format photo wider than 8192 px, 1:1 is its own pixels +#[test] +fn a_window_frame_can_be_as_big_as_the_photo() { + let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); + assert_eq!(p.window_edge, Some(11648)); +} + +// Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) +#[test] +fn a_drag_at_fit_does_not_switch_the_window_on() { + let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!(p.window_edge, None); +} + +// Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet +#[test] +fn a_pinch_holds_the_render_sizes() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + assert_ne!(fit, zoomed); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + assert_eq!(hold.apply(p, 0.0, false, fit), fit); + // the pinch runs for a second: every frame would plan something else + for k in 1..60 { + assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}"); + } + // it stopped at t = 59/60: still held for a moment (no flicker between two events)… + assert!(hold.holding(1.1)); + assert_eq!(hold.apply(p, 1.1, false, zoomed), fit); + // …then the view is planned for where it ended + assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01)); + assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed); + assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); +} + +// Given a pinch, the display-fit size holds like the render sizes and is retaken after it +#[test] +fn a_pinch_holds_the_display_fit_size() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + hold.apply(p, 0.0, false, fit); + assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]); + for k in 1..60 { + let now = k as f64 / 60.0; + hold.apply(p, now, true, zoomed); + assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}"); + } + // stopped at t = 59/60: still held within the hold time… + hold.apply(p, 1.1, false, zoomed); + assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]); + // …then the size it ended at is taken, and kept while nothing moves + hold.apply(p, 1.3, false, zoomed); + assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]); + assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]); + // another photo forgets the held size + hold.apply(p, 1.5, true, zoomed); + hold.display_fit(1.5, true, [2561, 2049]); + hold.apply(PhotoId(2), 1.6, true, zoomed); + assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]); +} + +// Given another photo, the held sizes are forgotten +#[test] +fn another_photo_is_planned_afresh() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + hold.apply(PhotoId(1), 0.0, false, fit); + assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); +} + +// Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line +#[test] +fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() { + use crate::state::BeforeAfter::*; + let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0)); + let (left, right) = ( + egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)), + egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)), + ); + assert_eq!(window_clip(SideBySide, true, left, canvas, left), left); + assert_eq!(window_clip(SideBySide, false, right, canvas, right), right); + let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0)); + let before = window_clip(Split, true, canvas, canvas, img); + let after = window_clip(Split, false, canvas, canvas, img); + assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); + assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); + // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle + let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); + assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); + assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); + assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); +} + +// Given a window asked for while another is rendering and the original isn't decoded yet, it +// waits for the decoded original instead of decoding another copy +#[test] +fn a_window_waits_for_the_original_another_is_decoding() { + assert!(defer_window(true, false)); + assert!(!defer_window(true, true), "the original is in"); + assert!(!defer_window(false, false), "nothing is decoding it"); +} + +// Hostile numbers give a plan, never a panic +#[test] +fn hostile_sizes_give_a_usable_plan() { + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { + let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); + assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); + } + let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); + assert!(p.main_edge >= 8); +} + +// Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid +#[test] +fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); + let m = margin_for(48_000); + assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); + assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); + assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); +} + +// Given a small pan, the same window is kept (no re-render); a big pan changes it +#[test] +fn small_pans_keep_the_window() { + let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); + let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap(); + assert_eq!(a, b); + let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap(); + assert_ne!(a, c); +} + +// Given a frame smaller than the margin, or a view at its edge, the window is clamped into it +#[test] +fn the_window_stays_inside_the_frame() { + let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap(); + assert_eq!((w.x, w.y), (0, 0)); + let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap(); + assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); + assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); +} + +// Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity +// and dehaze planes are 0.045 of the frame), up to a cap that bounds the window +#[test] +fn the_margin_grows_with_the_zoom_up_to_a_cap() { + assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); + assert!(margin_for(8000) > margin_for(3000)); + assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); + assert_eq!(margin_for(80_000), MAX_MARGIN); + assert_eq!(margin_for(0), SNAP); + assert!(margin_for(usize::MAX) <= MAX_MARGIN); + for l in [100, 3000, 8000, 48_000, 400_000] { + assert_eq!(margin_for(l) % SNAP, 0); + } +} + +// Given a tile rendered for other settings, another photo or another zoom, it is not drawn +#[test] +fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { + use lightcraft_catalog::PhotoId; + let v = + RegionView { photo: PhotoId(1), before: false, key: 7, full: (6000, 4000), window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, settings: 11 }; + assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); + assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); + assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); + assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); +} + +// Given a GPU that allows 2048 px textures, no window is wider than that +#[test] +fn a_window_fits_the_texture_side() { + for tex in [512, 2048, 4096] { + let span = max_span(tex); + assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}"); + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap(); + assert!(w.w <= span && w.h <= span, "{tex}: {w:?}"); + } + assert_eq!(max_span(100_000), MAX_SPAN); + assert_eq!(max_span(0), SNAP); +} + +// Given nothing visible, or hostile numbers, there is no window (never a panic) +#[test] +fn nothing_visible_or_hostile_input_is_no_window() { + assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None); + assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None); + assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None); + } + let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); +} + +// Given a view of an enormous area, the window is bounded +#[test] +fn a_window_is_bounded() { + let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 064f931d3..98356afb7 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -66,7 +66,23 @@ pub enum Slot { HdrHistogram, } +/// Settings that can change the rendered frame. Colour-only edits, including temporarily +/// hidden grading wheels, must not discard a decoded RAW's actual aspect ratio. +pub(crate) fn geometry_look(settings: &lightcraft_develop::DevelopSettings) -> u64 { + let geometry = lightcraft_develop::DevelopSettings { + crop: settings.crop, + orientation: settings.orientation, + geometry: settings.geometry.clone(), + optics: settings.optics, + disabled_sections: settings.disabled_sections.iter().filter(|s| matches!(s.as_str(), "geometry" | "optics")).cloned().collect(), + ..Default::default() + }; + geometry.hash64() +} + pub struct Tex { + /// Frame geometry and crop mode captured when these pixels were requested. + pub look: Option<(u64, bool)>, pub key: u64, pub photo: PhotoId, pub tex: egui::TextureHandle, @@ -93,12 +109,14 @@ pub trait RenderOffload { /// worker (`None` for prefetches and failures). A 10 MP zoom window's conversion took a few ms of /// the UI thread on every slider step. struct Finished { + look: Option<(u64, bool)>, result: RenderResult, color: Option>, } /// Immutable identity captured when a request is submitted, not when its result is collected. struct Requested { + look: Option<(u64, bool)>, id: u64, source: Option, } @@ -190,6 +208,8 @@ pub struct Renderer { #[cfg(test)] pub(crate) thumb_jobs_built: usize, requests: HashMap, + /// Only jobs actually accepted by an offload: queued replacements retain identity in the job. + offload_looks: HashMap, #[cfg(test)] failure_messages: Vec, /// Request id of the pixels each interactive view (loupe, before, hover) shows: a draft that @@ -200,8 +220,9 @@ pub struct Renderer { epoch: u64, preview_generation: Option<(std::sync::Weak, u64)>, /// Last thumbnail inputs; weak identities do not retain photo histories or force deep clones. - /// (photo, size bucket, job key, whether the request went through an embedded stand-in) - thumb_inputs: HashMap, usize, u64, bool)>, + /// (photo, size bucket, job key, whether the request went through an embedded stand-in, and the + /// priority of the caller that queued it — a stand-in's background render runs below that) + thumb_inputs: HashMap, usize, u64, bool, u32)>, /// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it). embedded_thumbs: HashMap, usize, u64)>, pool: JobPool, @@ -283,6 +304,7 @@ impl Renderer { #[cfg(test)] thumb_jobs_built: 0, requests: HashMap::new(), + offload_looks: HashMap::new(), #[cfg(test)] failure_messages: Vec::new(), shown_ids: HashMap::new(), @@ -323,11 +345,27 @@ impl Renderer { impl Renderer { /// Would asking for this thumbnail again (at `priority`) change nothing? pub fn thumb_current(&self, photo: &Arc, bucket: usize, priority: u32) -> bool { - let Some(&(ref old, size, key, quick)) = self.thumb_inputs.get(&photo.id) else { return false }; - if old.as_ptr() != Arc::as_ptr(photo) || size != bucket { + let Some(&(ref old, size, key, quick, _)) = self.thumb_inputs.get(&photo.id) else { return false }; + if old.as_ptr() != Arc::as_ptr(photo) { return false; } let slot = Slot::Thumb(photo.id); + if size > bucket { + // A larger thumbnail that has already arrived for this photo and settings serves a + // smaller cell too (cells at different sizes share one photo). Asking for the smaller + // bucket would replace the larger picture, and the larger cell would then ask for its + // bucket back on the next frame: two print cells would alternate renders forever. + // The photo identity above is the renderer's `Weak`: `Catalog` edits through + // `Arc::make_mut`, which (per std) moves the value to a new allocation whenever a + // `Weak` exists, even with one strong reference. An edit therefore breaks this match, + // and the key keeps an older picture of the same allocation from being reused. A + // larger request still renders once (below), and while it is in flight + // [`Self::thumb_pending_other_bucket`] holds the smaller callers. + return self.textures.get(&slot).is_some_and(|t| t.key == key); + } + if size != bucket { + return false; + } if quick && !self.textures.contains_key(&slot) { // Stand-in path (`grid::request_thumb`): the embedded preview is still on its way, or // the real thumbnail is requested at the background priority whatever the caller's. @@ -346,16 +384,30 @@ impl Renderer { /// Whether the camera's embedded preview *is* this photo's thumbnail: its quick render arrived /// (so `Slot::ThumbQuick` holds the picture) and no edit has moved the photo on to a settings /// hash of its own. The grid stops here instead of decoding the raw behind a thumbnail the - /// embedded JPEG already covers — see `grid::skips_the_real_render`. + /// embedded JPEG already covers — see `grid::skips_the_real_render`. A preview that arrived at + /// a larger bucket and is still held is final for a smaller cell too, so print cells at + /// different sizes do not replace it every frame. pub fn thumb_quick_is_final(&self, photo: &Arc, bucket: usize, priority: u32) -> bool { let Some((old, size, key)) = self.embedded_thumbs.get(&photo.id) else { return false }; - old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::ThumbQuick(photo.id), *key, priority) + if old.as_ptr() != Arc::as_ptr(photo) || *size < bucket { + return false; + } + let slot = Slot::ThumbQuick(photo.id); + if *size > bucket { + // A larger preview serves a smaller cell only when its pixels are actually here for + // the recorded key. The same-bucket failure memoization below says "no" after a failed + // job, but a missing larger preview is not a final picture: the smaller cell must ask + // for its own bucket rather than stay blank until the catalog revision changes. + return self.textures.get(&slot).is_some_and(|t| t.key == *key); + } + // The requested bucket itself: keep the failure memoization (`failed`) unchanged. + !self.request_needed(slot, *key, priority) } /// Whether this photo's embedded-preview thumbnail is pending at the requested priority, or /// already running. A queued priority change must reach the grid's stand-in request. pub fn thumb_quick_pending(&self, photo: &Arc, bucket: usize, priority: u32) -> bool { - let Some((old, size, key, quick)) = self.thumb_inputs.get(&photo.id) else { return false }; + let Some((old, size, key, quick, _)) = self.thumb_inputs.get(&photo.id) else { return false }; let slot = Slot::ThumbQuick(photo.id); old.as_ptr() == Arc::as_ptr(photo) && *size == bucket @@ -364,10 +416,44 @@ impl Renderer { && !self.request_needed(slot, *key, priority.saturating_add(1)) } + /// Whether a request for this photo's thumbnail is already in flight at another bucket and a + /// caller asking at `bucket` should leave it there. Several cells can show one photo at + /// different sizes at once (a print page's cells), and replacing `Slot::Thumb(id)` from each of + /// them every frame restarts the render, so none of them ever gets a picture. + /// + /// A caller with a higher priority than the one that queued the request takes it over once; + /// from then on the original caller coalesces behind that higher priority instead of replacing + /// it back. Equal priorities keep the request that is in flight (the print cells' case). This + /// only chooses which pending bucket survives; after it completes, a larger bucket is reused by + /// smaller callers ([`Self::thumb_current`]) rather than being replaced every frame. + /// + /// A full render queued behind a stand-in is submitted at [`crate::panels::grid::BACKGROUND_THUMB_PRIORITY`], + /// deliberately below an on-screen caller. That is not the caller's priority, so the comparison + /// uses the priority recorded with the inputs: comparing the background slot's own priority + /// would let two buckets keep replacing each other's background render every frame. + /// + /// A photo that changed (another `Arc`) or a texture for the recorded job key is not + /// coalesced: its callers ask for their own bucket as before. A stale texture from before an + /// edit does not count — it is still displayed while the replacement renders, and comparing + /// the key (not presence) keeps coalescing across buckets in the meantime. + pub fn thumb_pending_other_bucket(&self, photo: &Arc, bucket: usize, priority: u32) -> bool { + let Some(&(ref old, size, key, _, requested_priority)) = self.thumb_inputs.get(&photo.id) else { return false }; + if old.as_ptr() != Arc::as_ptr(photo) || size == bucket { + return false; + } + if self.textures.get(&Slot::Thumb(photo.id)).is_some_and(|t| t.key == key) { + return false; + } + // (the inputs must belong to the request still in flight, not to one that already ended) + let in_flight = self.pending.get(&Slot::ThumbQuick(photo.id)).is_some_and(|p| p.0 == key) + || self.pending.get(&Slot::Thumb(photo.id)).is_some_and(|p| p.0 == key); + in_flight && requested_priority >= priority + } + /// Remember that the camera's embedded preview is this photo's final thumbnail, so the grid's /// per-frame requests stop at it (the quick render's key, which [`Self::thumb_quick_is_final`] /// compares against). - pub fn remember_embedded_thumb(&mut self, photo: &Arc, bucket: usize, key: u64) { + pub fn remember_embedded_thumb(&mut self, photo: &Arc, bucket: usize, key: u64, priority: u32) { let slot = Slot::Thumb(photo.id); if self.pending.get(&slot).is_some_and(|(pending_key, _)| *pending_key != key) { // An edit can still be rendering when Undo restores this embedded-final thumbnail. @@ -377,20 +463,20 @@ impl Renderer { self.pool.reprioritize(|s, p| if *s == slot { None } else { Some(p) }); self.queue.retain(|q| q.slot != slot); } - self.remember_thumb(photo, bucket, key, true); + self.remember_thumb(photo, bucket, key, true, priority); self.embedded_thumbs.insert(photo.id, (Arc::downgrade(photo), bucket, key)); } - pub fn remember_thumb(&mut self, photo: &Arc, bucket: usize, key: u64, quick: bool) { + pub fn remember_thumb(&mut self, photo: &Arc, bucket: usize, key: u64, quick: bool, priority: u32) { if self.thumb_inputs.len() >= 1024 && !self.thumb_inputs.contains_key(&photo.id) { - self.thumb_inputs.retain(|_, (p, _, _, _)| p.strong_count() > 0); + self.thumb_inputs.retain(|_, (p, _, _, _, _)| p.strong_count() > 0); if self.thumb_inputs.len() >= 1024 && let Some(id) = self.thumb_inputs.keys().next().copied() { self.thumb_inputs.remove(&id); } } - self.thumb_inputs.insert(photo.id, (Arc::downgrade(photo), bucket, key, quick)); + self.thumb_inputs.insert(photo.id, (Arc::downgrade(photo), bucket, key, quick, priority)); } /// Views that render straight into the display profile's primaries (wide gamut); everything @@ -442,7 +528,7 @@ impl Renderer { lightcraft_engine::display::present(&mut r, d); } let color = r.rendered.as_ref().ok().map(|x| Arc::new(color_image(&x.image))); - Finished { result: r, color } + Finished { look: None, result: r, color } } fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool { @@ -491,7 +577,10 @@ impl Renderer { } self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); - self.requests.insert(slot, Requested { id: job.request_id, source: Some(job.origin.clone()) }); + self.requests.insert( + slot, + Requested { look: Some((geometry_look(&job.settings), job.request.apply_crop)), id: job.request_id, source: Some(job.origin.clone()) }, + ); // (an offload keeps its own per-view stage caches; the flag tells it to) let job = if slot.is_view() { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; if self.offload.is_some() { @@ -509,8 +598,9 @@ impl Renderer { key, priority, Box::new(move || { + let look = Some((geometry_look(&job.settings), job.request.apply_crop)); let run = || if background { lightcraft_engine::memory::in_background(|| job.run()) } else { job.run() }; - Self::run_for(run, &display) + Finished { look, ..Self::run_for(run, &display) } }), ); } @@ -544,7 +634,10 @@ impl Renderer { .as_ref() .map(|job| job.origin.clone()) .or_else(|| job.embedded.as_ref().map(|(path, _, _)| lightcraft_catalog::Source::File { path: path.clone() })); - self.requests.insert(slot, Requested { id: job.request_id, source }); + self.requests.insert( + slot, + Requested { look: job.small.as_ref().map(|j| (geometry_look(&j.settings), j.request.apply_crop)), id: job.request_id, source }, + ); let key = job.key; let display = self.display.clone(); self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display))); @@ -563,7 +656,7 @@ impl Renderer { } self.pending.insert(slot, (key, priority)); let request_id = lightcraft_preview::next_tick(); - self.requests.insert(slot, Requested { id: request_id, source: None }); + self.requests.insert(slot, Requested { look: None, id: request_id, source: None }); let display = self.display.clone(); self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| compose.result(request_id), &display))); } @@ -577,13 +670,16 @@ impl Renderer { self.prefetched.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); - self.requests.insert(slot, Requested { id: job.request_id, source: Some(job.origin.clone()) }); + self.requests.insert( + slot, + Requested { look: Some((geometry_look(&job.settings), job.request.apply_crop)), id: job.request_id, source: Some(job.origin.clone()) }, + ); let key = job.key; self.pool.submit( slot, key, priority, - Box::new(move || Finished { result: lightcraft_engine::memory::in_background(|| job.run()), color: None }), + Box::new(move || Finished { look: None, result: lightcraft_engine::memory::in_background(|| job.run()), color: None }), ); } @@ -608,6 +704,7 @@ impl Renderer { /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). pub fn forget_all(&mut self) { self.requests.clear(); + self.offload_looks.clear(); self.shown_ids.clear(); self.epoch = lightcraft_preview::next_tick(); self.preview_generation = None; @@ -653,10 +750,12 @@ impl Renderer { let Some(off) = self.offload.as_mut() else { return }; while let Some(i) = self.queue.iter().enumerate().max_by_key(|(_, q)| (q.priority, q.seq)).map(|(i, _)| i) { let q = self.queue.swap_remove(i); + let identity = (q.job.request_id, (geometry_look(&q.job.settings), q.job.request.apply_crop)); if let Some(job) = off.try_start(q.slot, q.job) { self.queue.push(Queued { job, ..q }); break; } + self.offload_looks.insert(identity.0, identity.1); } } @@ -665,6 +764,31 @@ impl Renderer { self.pending.len() } + /// Requests queued or running, excluding background preparation of neighbouring photos. + pub fn in_flight_without_prefetch(&self) -> usize { + self.pending.keys().filter(|slot| !matches!(slot, Slot::Prefetch(_))).count() + } + + /// Yield to native render workers until a result is ready or the timeout expires. + /// The result remains queued for the next poll. Browser and offloaded renderers do not wait. + /// Any native pending job, including prefetch, can satisfy the wait. A zero-worker test pool + /// must run its jobs inline first, or this call can only time out. + pub fn wait_for_worker(&mut self, timeout: std::time::Duration) -> bool { + if cfg!(target_arch = "wasm32") || self.offload.is_some() || self.pending.is_empty() { + return false; + } + self.pool.wait(timeout) + } + + /// Test seam: make the renderer look busy without starting a job (capture deadlines). + #[cfg(test)] + pub(crate) fn force_in_flight_for_test(&mut self, count: usize) { + self.pending.clear(); + for i in 0..count { + self.pending.insert(Slot::Prefetch((i % 256) as u8), (0, 0)); + } + } + /// Why the last render for `slot` failed (until a render with another key succeeds). pub fn failure(&self, slot: Slot) -> Option<&str> { self.failed.get(&slot).map(|f| f.1.as_str()) @@ -784,9 +908,9 @@ impl Renderer { // wasm: run one job per frame on this thread, timed with the host clock #[cfg(target_arch = "wasm32")] let inline_ms = { - let t0 = crate::now_ms(); + let t0 = crate::monotonic_ms(); self.pool.run_inline(1); - crate::now_ms() - t0 + crate::monotonic_ms() - t0 }; #[cfg(not(target_arch = "wasm32"))] let inline_ms = 0.0; @@ -823,14 +947,17 @@ impl Renderer { fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool { let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { - finished.push((done.slot, done.result.result, done.result.color, if done.ms > 0.0 { done.ms } else { inline_ms })); + finished.push((done.slot, done.result.result, done.result.color, done.result.look, if done.ms > 0.0 { done.ms } else { inline_ms })); } if let Some(off) = self.offload.as_mut() { - finished.extend(off.finished().into_iter().map(|(slot, r, ms)| (slot, r, None, ms))); + finished.extend(off.finished().into_iter().map(|(slot, r, ms)| { + let look = self.offload_looks.remove(&r.request_id); + (slot, r, None, look, ms) + })); } self.dispatch(); let mut changed = false; - for (mut slot, mut r, mut color, ms) in finished { + for (mut slot, mut r, mut color, completed_look, ms) in finished { self.completed += 1; // made for another display profile than the one in use: obsolete if r.display.is_some_and(|id| Some(id) != self.display_id()) { @@ -840,7 +967,7 @@ impl Renderer { // an interactive view still takes a superseded draft that is newer than what it // shows, and the source it decoded: a drag supersedes every request before it // finishes, and a source thrown away with them is decoded again for every tick. - let source = if self.requests.get(&slot).map(|request| request.id) != Some(r.request_id) { + let (source, look) = if self.requests.get(&slot).map(|request| request.id) != Some(r.request_id) { let newer_draft = slot.is_view() && self.requests.contains_key(&slot) && r.request_id >= self.epoch @@ -851,11 +978,13 @@ impl Renderer { continue; } session.accept(&r); - None // superseded successful drafts have no failure diagnostic + (None, completed_look) // retain the geometry of these pixels, never borrow the newer request’s identity } else { - let source = self.requests.remove(&slot).and_then(|request| request.source); + let request = self.requests.remove(&slot); + let look = request.as_ref().and_then(|request| request.look); + let source = request.and_then(|request| request.source); session.accept(&r); - source + (source, look) }; if slot.is_view() { self.shown_ids.insert(slot, r.request_id); @@ -917,6 +1046,7 @@ impl Renderer { Some(t) => { t.tex.set(color, egui::TextureOptions::LINEAR); t.pixels = pixels; + t.look = look; t.key = r.key; t.photo = r.photo; t.size = [img.width, img.height]; @@ -929,6 +1059,7 @@ impl Renderer { self.textures.insert( slot, Tex { + look, key: r.key, photo: r.photo, tex, @@ -1064,778 +1195,7 @@ fn render_failure_message(slot: Slot, photo: PhotoId, source: Option<&lightcraft } #[cfg(test)] -mod thumbnail_tests { - use super::*; - use lightcraft_catalog::{Catalog, Op, Photo, Source}; - - #[test] - fn render_failures_identify_the_file_for_thumbnail_and_view_slots() { - let source = Source::File { path: "/photos/trip/IMG_0042.nef".into() }; - for slot in [Slot::Thumb(PhotoId(42)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { - let message = render_failure_message(slot, PhotoId(42), Some(&source), "unsupported compression"); - assert!(message.contains("/photos/trip/IMG_0042.nef"), "{message}"); - assert!(message.contains("PhotoId(42)"), "{message}"); - assert!(message.ends_with(": unsupported compression"), "{message}"); - } - } - - #[test] - fn render_failure_paths_are_escaped_and_missing_sources_still_identified() { - let source = Source::File { path: "line\nbreak.nef".into() }; - let message = render_failure_message(Slot::Main, PhotoId(42), Some(&source), "decode failed"); - assert!(!message.contains('\n')); - assert!(message.contains("line\\nbreak.nef")); - assert!(render_failure_message(Slot::Main, PhotoId(42), None, "decode failed").contains("PhotoId(42), source unavailable")); - let demo = Source::Demo { scene: 7 }; - assert!(render_failure_message(Slot::Main, PhotoId(42), Some(&demo), "decode failed").contains("demo scene 7")); - } - - struct Blocked; - impl RenderOffload for Blocked { - fn try_start(&mut self, _: Slot, job: RenderJob) -> Option { - Some(job) - } - fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { - vec![] - } - } - fn photo() -> Arc { - Arc::new(Photo::new(PhotoId(1), Source::File { path: "audit.jpg".into() }, "audit.jpg", "JPEG", 6000, 4000, "2026-01-01")) - } - #[test] - fn snapshot_tracks_cow_edits_undo_reload_and_replacement() { - let mut catalog = Catalog::default(); - catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); - let old = catalog.photo(PhotoId(1)).unwrap().clone(); - let mut r = Renderer::default(); - r.remember_thumb(&old, 384, 77, false); - r.pending.insert(Slot::Thumb(PhotoId(1)), (77, 10)); - assert!(r.thumb_current(&old, 384, 10)); - assert!(!r.thumb_current(&old, 512, 10)); - let mut settings = lightcraft_develop::DevelopSettings::default(); - settings.light.exposure = 1.0; - let undo = catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "edit".into(), edited: None }).unwrap(); - assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); - catalog.apply(undo).unwrap(); - assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); - catalog - .apply(Op::SetContent { - id: PhotoId(1), - width: 6000, - height: 4000, - file_size: 99, - content_hash: Some("changed".into()), - preview_only: None, - preview_checked: String::new(), - }) - .unwrap(); - assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); - assert!(!r.thumb_current(&photo(), 384, 10)); - r.forget_all(); - assert!(r.thumb_inputs.is_empty()); - } - #[test] - fn queue_promotions_running_work_and_cancellation() { - let p = photo(); - let slot = Slot::Thumb(p.id); - let mut session = Session::new(); - session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap(); - let job = session.thumb_job(p.id, 384).unwrap(); - let key = job.key; - let mut r = Renderer::default(); - r.set_offload(Box::new(Blocked)); - r.remember_thumb(&p, 384, key, false); - r.request(slot, job, 5); - assert!(r.thumb_current(&p, 384, 5)); - assert!(!r.thumb_current(&p, 384, 10)); - r.request(slot, session.thumb_job(p.id, 384).unwrap(), 10); - assert_eq!(r.pending.get(&slot), Some(&(key, 10))); - r.queue.clear(); // running work: cannot reprioritize, just as Renderer::request - assert!(r.thumb_current(&p, 384, 5)); - r.pending.clear(); - assert!(!r.thumb_current(&p, 384, 10)); - r.evict_thumbs(&Default::default(), 600); - assert!(r.thumb_inputs.is_empty()); - } - fn queued_quick_thumbnail(id: PhotoId) -> (Arc, QuickJob) { - let mut session = Session::new(); - session.media.preview_loader = - Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); - let mut raw = (*photo()).clone(); - raw.id = id; - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - session.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - let p = session.catalog.photo(id).unwrap().clone(); - let job = session.thumb_job(id, 384).unwrap(); - (p, session.quick_thumb_job(&job).unwrap()) - } - - #[test] - fn queued_standin_promotes_from_prefetch_to_visible_without_restarting() { - let (p, quick) = queued_quick_thumbnail(PhotoId(1)); - let (_, other) = queued_quick_thumbnail(PhotoId(2)); - let slot = Slot::ThumbQuick(p.id); - // Deterministic queue: no native worker starts. - let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; - r.remember_thumb(&p, 384, quick.key, true); - r.request_quick(slot, quick.clone(), 6); // grid prefetch: 5 + 1 - r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); // filmstrip: 8 + 1 - let request_id = r.requests[&slot].id; - assert!(r.thumb_current(&p, 384, 5)); - assert!(!r.thumb_current(&p, 384, 10)); - r.request_quick(slot, quick.clone(), 11); // visible grid: 10 + 1 - assert!(r.thumb_current(&p, 384, 10)); - r.request_quick(slot, quick.clone(), 11); - assert_eq!(r.requests[&slot].id, request_id); - assert_eq!(r.pool.queued(), 2); - r.pool.run_inline(1); - let done = r.pool.try_recv().unwrap(); - assert_eq!(done.slot, slot); - assert_eq!(done.result.result.request_id, request_id); - assert_eq!(done.result.result.quick, Some(QuickSource::Embedded)); - // A finished result awaiting collection also must not start another extraction. - r.request_quick(slot, quick, 6); - assert!(r.thumb_current(&p, 384, 5)); - assert_eq!(r.pool.queued(), 1); - } - - #[test] - fn queued_standin_demotes_when_it_leaves_the_visible_grid() { - let (p, quick) = queued_quick_thumbnail(PhotoId(1)); - let (_, other) = queued_quick_thumbnail(PhotoId(2)); - let slot = Slot::ThumbQuick(p.id); - let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; - r.remember_thumb(&p, 384, quick.key, true); - r.request_quick(slot, quick.clone(), 11); - r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); - assert!(!r.thumb_current(&p, 384, 5)); - r.request_quick(slot, quick, 6); - assert!(r.thumb_current(&p, 384, 5)); - r.pool.run_inline(1); - assert_eq!(r.pool.try_recv().unwrap().slot, Slot::ThumbQuick(PhotoId(2))); - } - - #[test] - fn failures_retry_after_revision_and_quick_only_is_not_final() { - let p = photo(); - let slot = Slot::Thumb(p.id); - let mut r = Renderer::default(); - r.remember_thumb(&p, 384, 77, false); - r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); - assert!(!r.thumb_current(&p, 384, 10)); - r.failed.insert(slot, (77, "no decoder".into(), 0, p.id)); - assert!(r.thumb_current(&p, 384, 10)); - assert_eq!(r.failure_of(slot, p.id), Some("no decoder")); - assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error"); - r.catalog_rev = 1; - assert!(!r.thumb_current(&p, 384, 10)); - } - - #[test] - fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { - let mut s = Session::new(); - s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); - s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); - let mut r = Renderer::with_worker_threads(1); - let ctx = egui::Context::default(); - let budget = std::time::Duration::from_secs(10); - // the last loupe render took longer than the budget (a CPU render): not waited for - r.last_main_ms = 1e6; - r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); - r.wait_for_draft(&ctx, &mut s, budget); - assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); - // a fast one (GPU drafts): the texture is the new render when the wait returns - r.last_main_ms = 1.0; - r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); - let want = r.wanted(Slot::Main); - r.wait_for_draft(&ctx, &mut s, budget); - assert!(!r.is_pending(Slot::Main)); - assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); - } - - #[derive(Default)] - struct Work { - jobs: Vec<(Slot, RenderJob)>, - finished: Vec<(Slot, RenderResult, f64)>, - } - struct Manual(std::rc::Rc>); - impl RenderOffload for Manual { - fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { - self.0.borrow_mut().jobs.push((slot, job)); - None - } - fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { - std::mem::take(&mut self.0.borrow_mut().finished) - } - } - fn fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { - fixture_with(Session::new(), (*photo()).clone()) - } - fn fixture_with(mut s: Session, photo: Photo) -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { - s.media.file_loader = Some(Arc::new(|_, _| { - let mut image = lightcraft_raster::Rgb32f::new(8, 8); - image.data.fill([0.3, 0.2, 0.1]); - Ok((image, Default::default())) - })); - // (`with_fs` sets the file system's thumbnail loader, which wins over `file_loader`: thumbnails use the stub too) - s.media.reduced_loader = None; - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - let mut app = crate::LightcraftApp::new(s, crate::Services::default()); - app.renderer.keep_pixels = true; - let work = std::rc::Rc::new(std::cell::RefCell::new(Work::default())); - app.renderer.set_offload(Box::new(Manual(work.clone()))); - let ctx = egui::Context::default(); - app.renderer.poll(&ctx, &mut app.session); - (app, work, ctx) - } - fn take_job(work: &std::rc::Rc>) -> (Slot, RenderJob) { - work.borrow_mut().jobs.pop().unwrap() - } - fn finish(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context, slot: Slot, result: RenderResult) { - work.borrow_mut().finished.push((slot, result, 0.0)); - app.renderer.poll(ctx, &mut app.session); - } - fn request(app: &mut crate::LightcraftApp) { - crate::panels::grid::request_thumb(app, PhotoId(1), 256, 10); - } - - fn relink_destination() -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!("lc-render-relinked-{}-{}.nef", std::process::id(), lightcraft_preview::next_tick())); - std::fs::write(&path, b"replacement source").unwrap(); - path - } - - #[test] - fn completed_failure_keeps_requested_source_after_relink() { - let replacement = relink_destination(); - for slot in [Slot::Thumb(PhotoId(1)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { - let (mut app, work, ctx) = fixture(); - app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); - let job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); - app.renderer.request(slot, job, 100); - let (submitted, job) = take_job(&work); - assert_eq!(submitted, slot); - let result = job.run(); - assert!(result.rendered.is_err(), "the old request has already failed"); - app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); - assert!( - matches!(&app.session.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path } if path.ends_with(replacement.file_name().unwrap().to_str().unwrap())) - ); - finish(&mut app, &work, &ctx, slot, result); - assert_eq!(app.renderer.failure(slot), Some("simulated decode failure")); - assert!(!app.renderer.is_pending(slot)); - let message = app.renderer.failure_messages.last().expect("the collector emitted a diagnostic"); - assert!(message.contains("audit.jpg"), "{message}"); - assert!(!message.contains("lc-render-relinked-"), "the failed request did not read the replacement: {message}"); - assert!(message.ends_with(": simulated decode failure"), "{message}"); - } - std::fs::remove_file(replacement).unwrap(); - } - - #[test] - fn superseded_failure_does_not_take_the_new_requests_source() { - let replacement = relink_destination(); - let (mut app, work, ctx) = fixture(); - app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); - let slot = Slot::Main; - let old_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); - app.renderer.request(slot, old_job, 100); - let (_, old_job) = take_job(&work); - let old_result = old_job.run(); - app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); - let new_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); - app.renderer.request(slot, new_job, 100); - let (_, new_job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, old_result); - assert!(app.renderer.failure_messages.is_empty(), "obsolete failures stay suppressed"); - assert!(app.renderer.is_pending(slot)); - finish(&mut app, &work, &ctx, slot, new_job.run()); - let message = app.renderer.failure_messages.last().expect("new failure is reported"); - assert!(message.contains("lc-render-relinked-"), "{message}"); - assert!(!message.contains("audit.jpg"), "{message}"); - assert!(app.renderer.requests.is_empty(), "completed request identity is released"); - std::fs::remove_file(replacement).unwrap(); - } - - #[test] - fn grid_queued_standin_changes_priority_without_restarting() { - let mut s = Session::new(); - s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); - let mut raw = (*photo()).clone(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - let (mut app, work, ctx) = fixture_with(s, raw); - app.renderer.pool = JobPool::new(0); // deterministic: no quick job starts until run_inline - let id = PhotoId(1); - let slot = Slot::ThumbQuick(id); - - crate::panels::grid::request_thumb(&mut app, id, 384, 5); - let request_id = app.renderer.requests[&slot].id; - assert_eq!(app.renderer.pending[&slot].1, 6); - assert_eq!(app.renderer.pool.queued(), 1); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - assert_eq!(app.renderer.pending[&slot].1, 11, "the grid must promote its queued stand-in"); - assert_eq!(app.renderer.requests[&slot].id, request_id); - assert_eq!(app.renderer.pool.queued(), 1); - - crate::panels::grid::request_thumb(&mut app, id, 384, 5); - assert_eq!(app.renderer.pending[&slot].1, 6, "the grid must demote its queued stand-in"); - assert_eq!(app.renderer.requests[&slot].id, request_id); - assert_eq!(app.renderer.pool.queued(), 1); - let built = app.renderer.thumb_jobs_built; - app.renderer.pool.run_inline(1); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - assert_eq!(app.renderer.pending[&slot].1, 6, "completed work must not be restarted"); - assert_eq!(app.renderer.requests[&slot].id, request_id); - assert_eq!(app.renderer.pool.queued(), 0); - app.renderer.poll(&ctx, &mut app.session); - assert_eq!(app.renderer.textures[&slot].quick, Some(QuickSource::Embedded)); - - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 5); - assert_eq!(app.renderer.thumb_jobs_built, built); - assert_eq!(app.renderer.pool.queued(), 0); - assert!(!app.renderer.is_pending(Slot::Thumb(id))); - assert!(!app.renderer.textures.contains_key(&Slot::Thumb(id))); - assert!(work.borrow().jobs.is_empty(), "the final embedded thumbnail needs no real render"); - } - - #[test] - fn unchanged_ready_and_running_frames_build_no_jobs() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - assert_eq!(app.renderer.thumb_jobs_built, 1); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - let (slot, job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, job.run()); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert!(work.borrow().jobs.is_empty()); - } - - #[test] - fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, job.run()); - app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); - app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); - for v in [0.1, 0.2, 0.3] { - app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); - assert!(work.borrow().jobs.is_empty()); - app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); - request(&mut app); - assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); - } - - #[test] - fn unchanged_queued_frames_build_no_jobs() { - let (mut app, _, _) = fixture(); - app.renderer.set_offload(Box::new(Blocked)); - request(&mut app); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert_eq!(app.renderer.queue.len(), 1); - } - - #[test] - fn resizing_the_disk_cache_keeps_textures_and_skips_rebuilds() { - let lib = std::env::temp_dir().join(format!("lc-ui-thumb-resize-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&lib); - let mut s = Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - let (mut app, work, ctx) = fixture_with(s, (*photo()).clone()); - request(&mut app); - let (slot, job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, job.run()); - assert!(app.renderer.thumb(PhotoId(1)).is_some()); - let cache = app.session.media.rendered.clone(); - app.session.execute("library.preferences", &serde_json::json!({"cacheMb": 300})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - // thumbnails are keyed by content: a new budget leaves them (and the cache) valid - assert!(app.renderer.thumb(PhotoId(1)).is_some()); - assert!(Arc::ptr_eq(&cache, &app.session.media.rendered)); - for _ in 0..30 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert!(work.borrow().jobs.is_empty()); - // an explicit clear still drops them - app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - assert!(app.renderer.thumb(PhotoId(1)).is_none()); - drop(app); - let _ = std::fs::remove_dir_all(&lib); - } - - /// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting, - /// the error is kept (not retried every frame), and the pool's workers live on. Before, the - /// worker died without an answer and the slot stayed pending forever. - #[test] - fn a_panicking_render_is_a_failure_not_a_dead_worker() { - let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); - app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); - app.renderer.offload = None; // the real job pool - let slot = Slot::Thumb(PhotoId(1)); - let ask = |app: &mut crate::LightcraftApp| { - if let Some(job) = app.session.thumb_job(PhotoId(1), 256) { - app.renderer.request(slot, job, 30); - } - app.renderer.poll(&ctx, &mut app.session); - }; - ask(&mut app); - let t0 = std::time::Instant::now(); - while app.renderer.is_pending(slot) { - assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); - std::thread::sleep(std::time::Duration::from_millis(5)); - app.renderer.poll(&ctx, &mut app.session); - } - assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug")); - let workers = app.renderer.live_workers(); - assert!(workers > 0, "the worker that panicked is still there"); - let done = app.renderer.completed; - for _ in 0..30 { - ask(&mut app); - } - assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop"); - assert_eq!(app.renderer.live_workers(), workers); - } - - #[test] - fn raw_quick_path_frames_build_no_jobs() { - // an unedited raw: the grid shows its embedded preview (held here until released) and stops - // there — the camera JPEG at 4 416 × 2 944 already covers a 512 px thumbnail, so decoding the - // whole raw behind it only costs the file read (45 s a photo on a 1.2 MB/s share) - let gate = Arc::new(std::sync::Mutex::new(())); - let held = gate.lock().unwrap(); - let mut s = Session::new(); - let g = gate.clone(); - s.media.preview_loader = Some(Arc::new(move |_: &str, _| { - drop(g.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); - Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }) - })); - let mut raw = (*photo()).clone(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - let (mut app, _, ctx) = fixture_with(s, raw); - app.renderer.set_offload(Box::new(Blocked)); - let quick = Slot::ThumbQuick(PhotoId(1)); - // the stand-in is running - for _ in 0..180 { - request(&mut app); - } - assert!(app.renderer.is_pending(quick)); - assert_eq!(app.renderer.thumb_jobs_built, 1); - drop(held); - let t0 = std::time::Instant::now(); - while !app.renderer.textures.contains_key(&quick) { - assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the embedded preview never arrived"); - std::thread::sleep(std::time::Duration::from_millis(5)); - app.renderer.poll(&ctx, &mut app.session); - } - // the embedded preview is the thumbnail: no real render is queued behind it, and the grid - // asking every frame neither rebuilds the job nor re-requests the preview - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1, "an unedited raw's embedded preview is the thumbnail"); - assert!(app.renderer.queue.is_empty(), "nothing is decoded behind it"); - assert!(!app.renderer.is_pending(Slot::Thumb(PhotoId(1)))); - - // an edit takes the embedded preview out of the picture: the real thumbnail is rendered then - let mut edited = (*photo()).clone(); - edited.kind = lightcraft_catalog::MediaKind::Raw; - edited.develop = Arc::new({ - let mut d = edited.camera_defaults(); - d.light.exposure = 0.5; - d - }); - let (mut app, _, _ctx) = fixture_with(Session::new(), edited); - app.renderer.set_offload(Box::new(Blocked)); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert!(app.renderer.is_pending(Slot::Thumb(PhotoId(1))) || !app.renderer.queue.is_empty(), "an edited raw renders its thumbnail"); - } - - #[test] - fn closing_import_review_cancels_queued_and_running_identities() { - let (mut app, work, ctx) = fixture(); - let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); - app.renderer.request(Slot::Import(0), job.clone(), 10); - let (slot, running) = take_job(&work); - app.renderer.set_offload(Box::new(Blocked)); - app.renderer.request(Slot::Import(1), job, 10); - app.renderer.forget_imports(); - assert!(!app.renderer.is_pending(Slot::Import(0))); - assert!(!app.renderer.is_pending(Slot::Import(1))); - assert!(app.renderer.queue.is_empty()); - work.borrow_mut().finished.push((slot, running.run(), 0.0)); - app.renderer.set_offload(Box::new(Manual(work.clone()))); - app.renderer.poll(&ctx, &mut app.session); - assert!(!app.renderer.textures.contains_key(&slot)); - assert_eq!(app.session.media.source_usage().0, 0); - } - - #[test] - fn loupe_shows_superseded_drafts_newer_than_its_pixels_during_a_drag() { - let (mut app, work, ctx) = fixture(); - let mut jobs = Vec::new(); - for exposure in [0.0, 1.0, 2.0] { - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.light.exposure = exposure; - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); - let job = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft(); - app.renderer.request(Slot::Main, job, 100); - jobs.push(take_job(&work).1); - } - let keys: Vec = jobs.iter().map(|j| j.key).collect(); - let mut jobs = jobs.into_iter(); - let (first, second, last) = (jobs.next().unwrap(), jobs.next().unwrap(), jobs.next().unwrap()); - // the first draft lands while the newest is still rendering: the loupe moves on - finish(&mut app, &work, &ctx, Slot::Main, first.run()); - assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[0])); - assert!(app.renderer.is_pending(Slot::Main)); - finish(&mut app, &work, &ctx, Slot::Main, last.run()); - assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); - // a draft older than the pixels shown never replaces them - finish(&mut app, &work, &ctx, Slot::Main, second.run()); - assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); - assert!(!app.renderer.is_pending(Slot::Main)); - } - - #[test] - fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { - let (mut app, work, ctx) = fixture(); - let mut jobs = Vec::new(); - for exposure in [1.0, 2.0] { - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.hdr.enabled = true; - edit.light.exposure = exposure; - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); - let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); - app.renderer.request(Slot::HdrHistogram, job, 95); - jobs.push(take_job(&work).1); - } - let keys: Vec = jobs.iter().map(|j| j.key).collect(); - let mut jobs = jobs.into_iter(); - let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); - // superseded while the drag goes on, it still lands: the histogram keeps moving - finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); - let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); - assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); - assert!(live.2.hdr.is_some(), "HDR bins"); - finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); - assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); - // only the histogram is kept: no texture - assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); - } - - #[test] - fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, old) = take_job(&work); - let old_result = old.run(); - app.renderer.forget_all(); - let photo = app.session.catalog.photo(PhotoId(1)).unwrap().as_ref().clone(); - app.session = lightcraft_engine::Session::new(); - app.session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - app.session.media.file_loader = Some(Arc::new(|_, _| { - let mut image = lightcraft_raster::Rgb32f::new(8, 8); - image.data.fill([0.1, 0.2, 0.9]); - Ok((image, Default::default())) - })); - app.renderer.poll(&ctx, &mut app.session); - request(&mut app); - let (_, new) = take_job(&work); - assert_eq!(old_result.key, new.key); - assert_ne!(old_result.request_id, new.request_id); - let expected = new.clone().run().rendered.unwrap().image.as_bytes(); - finish(&mut app, &work, &ctx, slot, new.run()); - finish(&mut app, &work, &ctx, slot, old_result); - assert_eq!( - app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), - expected - ); - } - - #[test] - fn late_results_and_errors_cannot_replace_newest_pixels_or_sources() { - for order in [0, 1, 2] { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, old) = take_job(&work); - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.light.exposure = 1.0; - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); - request(&mut app); - let (_, new) = take_job(&work); - let expected = new.clone().run().rendered.unwrap().image.as_bytes(); - if order != 0 { - finish(&mut app, &work, &ctx, slot, new.run()); - // An obsolete source and error must not poison the current successful request. - let mut stale = old.run(); - if order == 2 { - stale.rendered = Err("old failure".into()); - } - finish(&mut app, &work, &ctx, slot, stale); - } else { - finish(&mut app, &work, &ctx, slot, old.run()); - assert!(app.renderer.thumb(PhotoId(1)).is_none()); - assert_eq!(app.session.media.source_usage().0, 0); - finish(&mut app, &work, &ctx, slot, new.run()); - } - assert_eq!( - app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), - expected - ); - assert!(app.renderer.failure(slot).is_none()); - } - } - - #[test] - fn undo_to_existing_texture_supersedes_pending_edit() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, first) = take_job(&work); - let key = first.key; - finish(&mut app, &work, &ctx, slot, first.run()); - let original = app.renderer.thumb(PhotoId(1)).unwrap().pixels.clone().unwrap(); - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.light.exposure = 1.0; - let undo = - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); - request(&mut app); - let (_, edited) = take_job(&work); - app.session.catalog.apply(undo).unwrap(); - request(&mut app); - let (_, restored) = take_job(&work); - assert_eq!(restored.key, key); - finish(&mut app, &work, &ctx, slot, restored.run()); - finish(&mut app, &work, &ctx, slot, edited.run()); - assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); - } - - #[test] - fn explicit_clear_repairs_same_key_bad_pixels_and_rejects_old_same_key_job() { - let (mut app, work, ctx) = fixture(); - let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); - let expected = job.clone().run().rendered.unwrap().image; - let (cache, key) = job.cache.clone().unwrap(); - let mut bad = expected.clone(); - bad.data.fill([255, 0, 255, 255]); - cache.put(key, Arc::new(bad.clone())); - request(&mut app); - let (slot, corrupt) = take_job(&work); - finish(&mut app, &work, &ctx, slot, corrupt.run()); - assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels[0].to_array(), [255, 0, 255, 255]); - // An old job can have the SAME render key and stale decoded pixels. - let mut old = job.clone(); - old.source = lightcraft_engine::media::SourceRef::Loaded(Box::new(lightcraft_engine::media::DecodedSource::new( - Arc::new(lightcraft_raster::Rgb32f::new(8, 8)), - None, - ))); - app.renderer.textures.clear(); - app.renderer.request(slot, old, 10); - let (_, old) = take_job(&work); - app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - assert!(app.renderer.thumb(PhotoId(1)).is_none()); - request(&mut app); - let (_, repaired) = take_job(&work); - assert_eq!(repaired.key, job.key); - finish(&mut app, &work, &ctx, slot, repaired.run()); - finish(&mut app, &work, &ctx, slot, old.run()); - assert_eq!(cache.get(key).unwrap().as_bytes(), expected.as_bytes()); - assert_eq!( - app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), - expected.as_bytes() - ); - } - - #[test] - fn undo_to_embedded_thumb_rejects_late_full_render() { - let mut s = Session::new(); - s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); - let mut raw = (*photo()).clone(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - let (mut app, work, ctx) = fixture_with(s, raw); - app.renderer.pool = JobPool::new(0); - let id = PhotoId(1); - let quick_slot = Slot::ThumbQuick(id); - let full_slot = Slot::Thumb(id); - let settings_a = app.session.catalog.photo(id).unwrap().develop.clone(); - - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - assert_eq!(app.renderer.pool.run_inline(1), 1); - app.renderer.poll(&ctx, &mut app.session); - let (key_a, pixels_a) = { - let shown = app.renderer.thumb(id).unwrap(); - assert_eq!(shown.quick, Some(QuickSource::Embedded)); - (shown.key, shown.pixels.as_ref().unwrap().pixels.clone()) - }; - assert!(app.renderer.textures.contains_key(&quick_slot)); - assert!(!app.renderer.textures.contains_key(&full_slot)); - assert!(work.borrow().jobs.is_empty()); - - let mut settings_b = (*settings_a).clone(); - settings_b.light.exposure += 0.5; - app.session.commit("Exposure", Op::SetDevelop { id, settings: Arc::new(settings_b), label: "Exposure".into(), edited: None }).unwrap(); - assert!(!app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - // Manual owns dispatched jobs: hold B until the real Undo command restores A. - let (slot, held_b) = take_job(&work); - assert_eq!(slot, full_slot); - let key_b = held_b.key; - assert_ne!(key_b, key_a); - assert!(app.renderer.is_pending(full_slot)); - - app.run("edit.undo", serde_json::json!({})).unwrap(); - assert_eq!(app.session.catalog.photo(id).unwrap().develop.as_ref(), settings_a.as_ref()); - assert!(app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - { - let restored = app.renderer.thumb(id).unwrap(); - assert_eq!(restored.key, key_a); - assert_eq!(restored.pixels.as_ref().unwrap().pixels, pixels_a); - assert_eq!(restored.quick, Some(QuickSource::Embedded)); - } - assert!(work.borrow().jobs.is_empty(), "restoring embedded-final A needs no full render"); - assert_eq!(app.renderer.pool.queued(), 0); - - let result_b = held_b.run(); - assert_eq!(result_b.key, key_b); - let rendered_b = result_b.rendered.as_ref().expect("held edited thumbnail must render before the stale-result oracle"); - assert_ne!(color_image(&rendered_b.image).pixels, pixels_a, "the held result must be distinguishable from A"); - finish(&mut app, &work, &ctx, slot, result_b); - - let shown = app.renderer.thumb(id).unwrap(); - assert_eq!(shown.key, key_a, "late edited Thumb(B) must not replace restored embedded ThumbQuick(A)"); - assert_eq!(shown.pixels.as_ref().unwrap().pixels, pixels_a); - assert_eq!(shown.quick, Some(QuickSource::Embedded)); - let quick = app.renderer.textures.get(&quick_slot).expect("the correct embedded thumbnail must remain installed"); - assert_eq!(quick.key, key_a); - assert_eq!(quick.quick, Some(QuickSource::Embedded)); - assert_eq!(quick.pixels.as_ref().unwrap().pixels, pixels_a); - assert!(!app.renderer.is_pending(full_slot)); - } -} +mod thumbnail_tests; #[cfg(test)] mod stage_budget_tests { @@ -1851,7 +1211,10 @@ mod stage_budget_tests { let ctx = egui::Context::default(); let mut r = Renderer::default(); let tex = ctx.load_texture("window", egui::ColorImage::new([1, 1], vec![egui::Color32::BLACK]), Default::default()); - r.textures.insert(Slot::Region, Tex { key: 1, photo: PhotoId(1), tex, size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None }); + r.textures.insert( + Slot::Region, + Tex { look: None, key: 1, photo: PhotoId(1), tex, size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None }, + ); let long_ago = std::time::Instant::now().checked_sub(IDLE_TRIM * 2).unwrap(); r.idle = Some((long_ago, false)); r.trim_when_idle(&ctx); diff --git a/crates/ui-egui/src/render/thumbnail_tests.rs b/crates/ui-egui/src/render/thumbnail_tests.rs new file mode 100644 index 000000000..eaf4e8f2f --- /dev/null +++ b/crates/ui-egui/src/render/thumbnail_tests.rs @@ -0,0 +1,1174 @@ +use super::*; +use lightcraft_catalog::{Catalog, Op, Photo, Source}; + +#[test] +fn render_failures_identify_the_file_for_thumbnail_and_view_slots() { + let source = Source::File { path: "/photos/trip/IMG_0042.nef".into() }; + for slot in [Slot::Thumb(PhotoId(42)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { + let message = render_failure_message(slot, PhotoId(42), Some(&source), "unsupported compression"); + assert!(message.contains("/photos/trip/IMG_0042.nef"), "{message}"); + assert!(message.contains("PhotoId(42)"), "{message}"); + assert!(message.ends_with(": unsupported compression"), "{message}"); + } +} + +#[test] +fn render_failure_paths_are_escaped_and_missing_sources_still_identified() { + let source = Source::File { path: "line\nbreak.nef".into() }; + let message = render_failure_message(Slot::Main, PhotoId(42), Some(&source), "decode failed"); + assert!(!message.contains('\n')); + assert!(message.contains("line\\nbreak.nef")); + assert!(render_failure_message(Slot::Main, PhotoId(42), None, "decode failed").contains("PhotoId(42), source unavailable")); + let demo = Source::Demo { scene: 7 }; + assert!(render_failure_message(Slot::Main, PhotoId(42), Some(&demo), "decode failed").contains("demo scene 7")); +} + +struct Blocked; +impl RenderOffload for Blocked { + fn try_start(&mut self, _: Slot, job: RenderJob) -> Option { + Some(job) + } + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + vec![] + } +} +fn photo() -> Arc { + Arc::new(Photo::new(PhotoId(1), Source::File { path: "audit.jpg".into() }, "audit.jpg", "JPEG", 6000, 4000, "2026-01-01")) +} + +#[test] +fn capture_readiness_excludes_prefetch_but_keeps_photo_and_thumbnail_work() { + let p = photo(); + let mut session = Session::new(); + session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap(); + // Keep real requests queued without starting native workers. + let mut renderer = Renderer { pool: JobPool::new(0), ..Default::default() }; + renderer.prefetch(Slot::Prefetch(0), session.thumb_job(p.id, 384).unwrap(), 1); + renderer.prefetch(Slot::Prefetch(1), session.thumb_job(p.id, 384).unwrap(), 1); + assert_eq!(renderer.in_flight(), 2); + assert_eq!(renderer.in_flight_without_prefetch(), 0); + for (index, slot) in [Slot::Main, Slot::Thumb(p.id), Slot::Variant(7), Slot::Preview, Slot::ThumbQuick(p.id)].into_iter().enumerate() { + renderer.request(slot, session.thumb_job(p.id, 384).unwrap(), 10); + assert_eq!(renderer.in_flight_without_prefetch(), index + 1); + assert_eq!(renderer.in_flight(), index + 3); + } + assert_eq!(renderer.pool.run_inline(7), 7); + // Waiting observes a completed job without swallowing the result needed by poll. + assert!(renderer.wait_for_worker(std::time::Duration::ZERO)); + renderer.poll(&egui::Context::default(), &mut session); + assert_eq!(renderer.in_flight_without_prefetch(), 0); + assert_eq!(renderer.in_flight(), 0); +} +#[test] +fn snapshot_tracks_cow_edits_undo_reload_and_replacement() { + let mut catalog = Catalog::default(); + catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let old = catalog.photo(PhotoId(1)).unwrap().clone(); + let mut r = Renderer::default(); + r.remember_thumb(&old, 384, 77, false, 10); + r.pending.insert(Slot::Thumb(PhotoId(1)), (77, 10)); + assert!(r.thumb_current(&old, 384, 10)); + assert!(!r.thumb_current(&old, 512, 10)); + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.light.exposure = 1.0; + let undo = catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "edit".into(), edited: None }).unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + catalog.apply(undo).unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + catalog + .apply(Op::SetContent { + id: PhotoId(1), + width: 6000, + height: 4000, + file_size: 99, + content_hash: Some("changed".into()), + preview_only: None, + preview_checked: String::new(), + }) + .unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + assert!(!r.thumb_current(&photo(), 384, 10)); + r.forget_all(); + assert!(r.thumb_inputs.is_empty()); +} +#[test] +fn queue_promotions_running_work_and_cancellation() { + let p = photo(); + let slot = Slot::Thumb(p.id); + let mut session = Session::new(); + session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap(); + let job = session.thumb_job(p.id, 384).unwrap(); + let key = job.key; + let mut r = Renderer::default(); + r.set_offload(Box::new(Blocked)); + r.remember_thumb(&p, 384, key, false, 5); + r.request(slot, job, 5); + assert!(r.thumb_current(&p, 384, 5)); + assert!(!r.thumb_current(&p, 384, 10)); + r.request(slot, session.thumb_job(p.id, 384).unwrap(), 10); + assert_eq!(r.pending.get(&slot), Some(&(key, 10))); + r.queue.clear(); // running work: cannot reprioritize, just as Renderer::request + assert!(r.thumb_current(&p, 384, 5)); + r.pending.clear(); + assert!(!r.thumb_current(&p, 384, 10)); + r.evict_thumbs(&Default::default(), 600); + assert!(r.thumb_inputs.is_empty()); +} +fn queued_quick_thumbnail(id: PhotoId) -> (Arc, QuickJob) { + let mut session = Session::new(); + session.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.id = id; + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + session.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let p = session.catalog.photo(id).unwrap().clone(); + let job = session.thumb_job(id, 384).unwrap(); + (p, session.quick_thumb_job(&job).unwrap()) +} + +#[test] +fn queued_standin_promotes_from_prefetch_to_visible_without_restarting() { + let (p, quick) = queued_quick_thumbnail(PhotoId(1)); + let (_, other) = queued_quick_thumbnail(PhotoId(2)); + let slot = Slot::ThumbQuick(p.id); + // Deterministic queue: no native worker starts. + let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; + r.remember_thumb(&p, 384, quick.key, true, 5); + r.request_quick(slot, quick.clone(), 6); // grid prefetch: 5 + 1 + r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); // filmstrip: 8 + 1 + let request_id = r.requests[&slot].id; + assert!(r.thumb_current(&p, 384, 5)); + assert!(!r.thumb_current(&p, 384, 10)); + r.request_quick(slot, quick.clone(), 11); // visible grid: 10 + 1 + assert!(r.thumb_current(&p, 384, 10)); + r.request_quick(slot, quick.clone(), 11); + assert_eq!(r.requests[&slot].id, request_id); + assert_eq!(r.pool.queued(), 2); + r.pool.run_inline(1); + let done = r.pool.try_recv().unwrap(); + assert_eq!(done.slot, slot); + assert_eq!(done.result.result.request_id, request_id); + assert_eq!(done.result.result.quick, Some(QuickSource::Embedded)); + // A finished result awaiting collection also must not start another extraction. + r.request_quick(slot, quick, 6); + assert!(r.thumb_current(&p, 384, 5)); + assert_eq!(r.pool.queued(), 1); +} + +#[test] +fn queued_standin_demotes_when_it_leaves_the_visible_grid() { + let (p, quick) = queued_quick_thumbnail(PhotoId(1)); + let (_, other) = queued_quick_thumbnail(PhotoId(2)); + let slot = Slot::ThumbQuick(p.id); + let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; + r.remember_thumb(&p, 384, quick.key, true, 10); + r.request_quick(slot, quick.clone(), 11); + r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); + assert!(!r.thumb_current(&p, 384, 5)); + r.request_quick(slot, quick, 6); + assert!(r.thumb_current(&p, 384, 5)); + r.pool.run_inline(1); + assert_eq!(r.pool.try_recv().unwrap().slot, Slot::ThumbQuick(PhotoId(2))); +} + +#[test] +fn failures_retry_after_revision_and_quick_only_is_not_final() { + let p = photo(); + let slot = Slot::Thumb(p.id); + let mut r = Renderer::default(); + r.remember_thumb(&p, 384, 77, false, 10); + r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); + assert!(!r.thumb_current(&p, 384, 10)); + r.failed.insert(slot, (77, "no decoder".into(), 0, p.id)); + assert!(r.thumb_current(&p, 384, 10)); + assert_eq!(r.failure_of(slot, p.id), Some("no decoder")); + assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error"); + r.catalog_rev = 1; + assert!(!r.thumb_current(&p, 384, 10)); +} + +/// A larger texture only serves a smaller bucket when it is the recorded request's picture. +/// This isolates the key guard: under a `contains_key`-only check the first assertion below +/// would return `true`. +#[test] +fn larger_texture_for_another_key_does_not_count_as_current() { + let p = photo(); + let slot = Slot::Thumb(p.id); + let ctx = egui::Context::default(); + let tex = |key| Tex { + look: None, + key, + photo: p.id, + tex: ctx.load_texture(format!("thumb-{key}"), egui::ColorImage::new([1, 1], vec![egui::Color32::BLACK]), Default::default()), + size: [1, 1], + histogram: None, + ms: 0.0, + quick: None, + pixels: None, + }; + let mut r = Renderer::default(); + // A completed larger bucket: the remembered request is bucket 512, key 77. + r.remember_thumb(&p, 512, 77, false, 10); + // Another key's pixels (for example the picture from before an edit) may still be + // displayed, but they are not the remembered request's picture. + r.textures.insert(slot, tex(66)); + assert!(!r.thumb_current(&p, 256, 10), "a mismatched texture key must not serve a smaller bucket"); + r.textures.insert(slot, tex(77)); + assert!(r.thumb_current(&p, 256, 10), "the recorded key's larger texture serves the smaller bucket"); +} + +#[test] +fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { + let mut s = Session::new(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); + s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let mut r = Renderer::with_worker_threads(1); + let ctx = egui::Context::default(); + let budget = std::time::Duration::from_secs(10); + // the last loupe render took longer than the budget (a CPU render): not waited for + r.last_main_ms = 1e6; + r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); + // a fast one (GPU drafts): the texture is the new render when the wait returns + r.last_main_ms = 1.0; + r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); + let want = r.wanted(Slot::Main); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(!r.is_pending(Slot::Main)); + assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); +} + +#[derive(Default)] +struct Work { + jobs: Vec<(Slot, RenderJob)>, + finished: Vec<(Slot, RenderResult, f64)>, +} +struct Manual(std::rc::Rc>); +impl RenderOffload for Manual { + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { + self.0.borrow_mut().jobs.push((slot, job)); + None + } + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + std::mem::take(&mut self.0.borrow_mut().finished) + } +} +fn fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { + fixture_with(Session::new(), (*photo()).clone()) +} +/// An unedited raw: its embedded camera preview is its final thumbnail (the grid's quick path). +fn raw_quick_fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + fixture_with(s, raw) +} +fn fixture_with(mut s: Session, photo: Photo) -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { + s.media.file_loader = Some(Arc::new(|_, _| { + let mut image = lightcraft_raster::Rgb32f::new(8, 8); + image.data.fill([0.3, 0.2, 0.1]); + Ok((image, Default::default())) + })); + // (`with_fs` sets the file system's thumbnail loader, which wins over `file_loader`: thumbnails use the stub too) + s.media.reduced_loader = None; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let mut app = crate::LightcraftApp::new(s, crate::Services::default()); + app.renderer.keep_pixels = true; + let work = std::rc::Rc::new(std::cell::RefCell::new(Work::default())); + app.renderer.set_offload(Box::new(Manual(work.clone()))); + let ctx = egui::Context::default(); + app.renderer.poll(&ctx, &mut app.session); + (app, work, ctx) +} +fn take_job(work: &std::rc::Rc>) -> (Slot, RenderJob) { + work.borrow_mut().jobs.pop().unwrap() +} +fn finish(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context, slot: Slot, result: RenderResult) { + work.borrow_mut().finished.push((slot, result, 0.0)); + app.renderer.poll(ctx, &mut app.session); +} +fn request(app: &mut crate::LightcraftApp) { + crate::panels::grid::request_thumb(app, PhotoId(1), 256, 10); +} + +fn relink_destination() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("lc-render-relinked-{}-{}.nef", std::process::id(), lightcraft_preview::next_tick())); + std::fs::write(&path, b"replacement source").unwrap(); + path +} + +#[test] +fn completed_failure_keeps_requested_source_after_relink() { + let replacement = relink_destination(); + for slot in [Slot::Thumb(PhotoId(1)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { + let (mut app, work, ctx) = fixture(); + app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); + let job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, job, 100); + let (submitted, job) = take_job(&work); + assert_eq!(submitted, slot); + let result = job.run(); + assert!(result.rendered.is_err(), "the old request has already failed"); + app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); + assert!( + matches!(&app.session.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path } if path.ends_with(replacement.file_name().unwrap().to_str().unwrap())) + ); + finish(&mut app, &work, &ctx, slot, result); + assert_eq!(app.renderer.failure(slot), Some("simulated decode failure")); + assert!(!app.renderer.is_pending(slot)); + let message = app.renderer.failure_messages.last().expect("the collector emitted a diagnostic"); + assert!(message.contains("audit.jpg"), "{message}"); + assert!(!message.contains("lc-render-relinked-"), "the failed request did not read the replacement: {message}"); + assert!(message.ends_with(": simulated decode failure"), "{message}"); + } + std::fs::remove_file(replacement).unwrap(); +} + +#[test] +fn superseded_failure_does_not_take_the_new_requests_source() { + let replacement = relink_destination(); + let (mut app, work, ctx) = fixture(); + app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); + let slot = Slot::Main; + let old_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, old_job, 100); + let (_, old_job) = take_job(&work); + let old_result = old_job.run(); + app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); + let new_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, new_job, 100); + let (_, new_job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, old_result); + assert!(app.renderer.failure_messages.is_empty(), "obsolete failures stay suppressed"); + assert!(app.renderer.is_pending(slot)); + finish(&mut app, &work, &ctx, slot, new_job.run()); + let message = app.renderer.failure_messages.last().expect("new failure is reported"); + assert!(message.contains("lc-render-relinked-"), "{message}"); + assert!(!message.contains("audit.jpg"), "{message}"); + assert!(app.renderer.requests.is_empty(), "completed request identity is released"); + std::fs::remove_file(replacement).unwrap(); +} + +#[test] +fn grid_queued_standin_changes_priority_without_restarting() { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, work, ctx) = fixture_with(s, raw); + app.renderer.pool = JobPool::new(0); // deterministic: no quick job starts until run_inline + let id = PhotoId(1); + let slot = Slot::ThumbQuick(id); + + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + let request_id = app.renderer.requests[&slot].id; + assert_eq!(app.renderer.pending[&slot].1, 6); + assert_eq!(app.renderer.pool.queued(), 1); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pending[&slot].1, 11, "the grid must promote its queued stand-in"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 1); + + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + assert_eq!(app.renderer.pending[&slot].1, 6, "the grid must demote its queued stand-in"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 1); + let built = app.renderer.thumb_jobs_built; + app.renderer.pool.run_inline(1); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pending[&slot].1, 6, "completed work must not be restarted"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 0); + app.renderer.poll(&ctx, &mut app.session); + assert_eq!(app.renderer.textures[&slot].quick, Some(QuickSource::Embedded)); + + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + assert_eq!(app.renderer.thumb_jobs_built, built); + assert_eq!(app.renderer.pool.queued(), 0); + assert!(!app.renderer.is_pending(Slot::Thumb(id))); + assert!(!app.renderer.textures.contains_key(&Slot::Thumb(id))); + assert!(work.borrow().jobs.is_empty(), "the final embedded thumbnail needs no real render"); +} + +#[test] +fn unchanged_ready_and_running_frames_build_no_jobs() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 1); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert!(work.borrow().jobs.is_empty()); +} + +#[test] +fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); + for v in [0.1, 0.2, 0.3] { + app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); + assert!(work.borrow().jobs.is_empty()); + app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); +} + +#[test] +fn unchanged_queued_frames_build_no_jobs() { + let (mut app, _, _) = fixture(); + app.renderer.set_offload(Box::new(Blocked)); + request(&mut app); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert_eq!(app.renderer.queue.len(), 1); +} + +#[test] +fn resizing_the_disk_cache_keeps_textures_and_skips_rebuilds() { + let lib = std::env::temp_dir().join(format!("lc-ui-thumb-resize-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&lib); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let (mut app, work, ctx) = fixture_with(s, (*photo()).clone()); + request(&mut app); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + assert!(app.renderer.thumb(PhotoId(1)).is_some()); + let cache = app.session.media.rendered.clone(); + app.session.execute("library.preferences", &serde_json::json!({"cacheMb": 300})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + // thumbnails are keyed by content: a new budget leaves them (and the cache) valid + assert!(app.renderer.thumb(PhotoId(1)).is_some()); + assert!(Arc::ptr_eq(&cache, &app.session.media.rendered)); + for _ in 0..30 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert!(work.borrow().jobs.is_empty()); + // an explicit clear still drops them + app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + drop(app); + let _ = std::fs::remove_dir_all(&lib); +} + +/// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting, +/// the error is kept (not retried every frame), and the pool's workers live on. Before, the +/// worker died without an answer and the slot stayed pending forever. +#[test] +fn a_panicking_render_is_a_failure_not_a_dead_worker() { + let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); + app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + app.renderer.offload = None; // the real job pool + let slot = Slot::Thumb(PhotoId(1)); + let ask = |app: &mut crate::LightcraftApp| { + if let Some(job) = app.session.thumb_job(PhotoId(1), 256) { + app.renderer.request(slot, job, 30); + } + app.renderer.poll(&ctx, &mut app.session); + }; + ask(&mut app); + let t0 = std::time::Instant::now(); + while app.renderer.is_pending(slot) { + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.renderer.poll(&ctx, &mut app.session); + } + assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug")); + let workers = app.renderer.live_workers(); + assert!(workers > 0, "the worker that panicked is still there"); + let done = app.renderer.completed; + for _ in 0..30 { + ask(&mut app); + } + assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop"); + assert_eq!(app.renderer.live_workers(), workers); +} + +#[test] +fn raw_quick_path_frames_build_no_jobs() { + // an unedited raw: the grid shows its embedded preview (held here until released) and stops + // there — the camera JPEG at 4 416 × 2 944 already covers a 512 px thumbnail, so decoding the + // whole raw behind it only costs the file read (45 s a photo on a 1.2 MB/s share) + let gate = Arc::new(std::sync::Mutex::new(())); + let held = gate.lock().unwrap(); + let mut s = Session::new(); + let g = gate.clone(); + s.media.preview_loader = Some(Arc::new(move |_: &str, _| { + drop(g.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }) + })); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, _, ctx) = fixture_with(s, raw); + app.renderer.set_offload(Box::new(Blocked)); + let quick = Slot::ThumbQuick(PhotoId(1)); + // the stand-in is running + for _ in 0..180 { + request(&mut app); + } + assert!(app.renderer.is_pending(quick)); + assert_eq!(app.renderer.thumb_jobs_built, 1); + drop(held); + let t0 = std::time::Instant::now(); + while !app.renderer.textures.contains_key(&quick) { + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the embedded preview never arrived"); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.renderer.poll(&ctx, &mut app.session); + } + // the embedded preview is the thumbnail: no real render is queued behind it, and the grid + // asking every frame neither rebuilds the job nor re-requests the preview + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1, "an unedited raw's embedded preview is the thumbnail"); + assert!(app.renderer.queue.is_empty(), "nothing is decoded behind it"); + assert!(!app.renderer.is_pending(Slot::Thumb(PhotoId(1)))); + + // an edit takes the embedded preview out of the picture: the real thumbnail is rendered then + let mut edited = (*photo()).clone(); + edited.kind = lightcraft_catalog::MediaKind::Raw; + edited.develop = Arc::new({ + let mut d = edited.camera_defaults(); + d.light.exposure = 0.5; + d + }); + let (mut app, _, _ctx) = fixture_with(Session::new(), edited); + app.renderer.set_offload(Box::new(Blocked)); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert!(app.renderer.is_pending(Slot::Thumb(PhotoId(1))) || !app.renderer.queue.is_empty(), "an edited raw renders its thumbnail"); +} + +#[test] +fn closing_import_review_cancels_queued_and_running_identities() { + let (mut app, work, ctx) = fixture(); + let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); + app.renderer.request(Slot::Import(0), job.clone(), 10); + let (slot, running) = take_job(&work); + app.renderer.set_offload(Box::new(Blocked)); + app.renderer.request(Slot::Import(1), job, 10); + app.renderer.forget_imports(); + assert!(!app.renderer.is_pending(Slot::Import(0))); + assert!(!app.renderer.is_pending(Slot::Import(1))); + assert!(app.renderer.queue.is_empty()); + work.borrow_mut().finished.push((slot, running.run(), 0.0)); + app.renderer.set_offload(Box::new(Manual(work.clone()))); + app.renderer.poll(&ctx, &mut app.session); + assert!(!app.renderer.textures.contains_key(&slot)); + assert_eq!(app.session.media.source_usage().0, 0); +} + +#[test] +fn loupe_shows_superseded_drafts_newer_than_its_pixels_during_a_drag() { + let (mut app, work, ctx) = fixture(); + let mut jobs = Vec::new(); + for exposure in [0.0, 1.0, 2.0] { + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = exposure; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); + let job = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft(); + app.renderer.request(Slot::Main, job, 100); + jobs.push(take_job(&work).1); + } + let keys: Vec = jobs.iter().map(|j| j.key).collect(); + let mut jobs = jobs.into_iter(); + let (first, second, last) = (jobs.next().unwrap(), jobs.next().unwrap(), jobs.next().unwrap()); + // A decoded embedded preview can differ from the catalog's square metadata. Its + // superseded colour-only draft must retain the identity that enables aspect correction. + let mut first_result = first.run(); + first_result.rendered.as_mut().unwrap().image = lightcraft_raster::Rgba8::new(8, 4); + finish(&mut app, &work, &ctx, Slot::Main, first_result); + assert_eq!(app.renderer.textures[&Slot::Main].size, [8, 4]); + assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[0])); + assert!(app.renderer.is_pending(Slot::Main)); + assert_eq!( + app.renderer.textures[&Slot::Main].look, + Some((geometry_look(&lightcraft_develop::DevelopSettings::default()), true)), + "colour-only superseded pixels retain their geometry" + ); + let current_look = geometry_look(&last.settings); + finish(&mut app, &work, &ctx, Slot::Main, last.run()); + assert_eq!(app.renderer.textures[&Slot::Main].look, Some((current_look, true))); + assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); + // a draft older than the pixels shown never replaces them + finish(&mut app, &work, &ctx, Slot::Main, second.run()); + assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); + assert!(!app.renderer.is_pending(Slot::Main)); + assert!(app.renderer.offload_looks.is_empty(), "completed offload identities are released"); +} + +#[test] +fn superseded_crop_keeps_its_own_geometry_instead_of_the_latest_requests() { + let (mut app, work, ctx) = fixture(); + let old = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft(); + let old_look = geometry_look(&old.settings); + app.renderer.request(Slot::Main, old, 100); + let (_, old) = take_job(&work); + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, 0.5, 1.0); + let new_look = geometry_look(&settings); + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "crop".into(), edited: None }).unwrap(); + let current = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft(); + app.renderer.request(Slot::Main, current, 100); + let (_, current) = take_job(&work); + finish(&mut app, &work, &ctx, Slot::Main, old.run()); + assert_eq!(app.renderer.textures[&Slot::Main].look, Some((old_look, true))); + assert_ne!(old_look, new_look); + finish(&mut app, &work, &ctx, Slot::Main, current.run()); + assert_eq!(app.renderer.textures[&Slot::Main].look, Some((new_look, true))); +} + +#[test] +fn hidden_grading_keeps_the_completed_textures_geometry_identity() { + let (mut app, work, ctx) = fixture(); + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.grading.shadows.sat = 40.0; + settings.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.7); + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings.clone()), label: "grade".into(), edited: None }).unwrap(); + let expected = geometry_look(&settings); + let job = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().with_hidden_grading([true, false, false]); + assert_ne!(job.settings.hash64(), settings.hash64(), "the view really hides a nonzero wheel"); + app.renderer.request(Slot::Main, job, 100); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + assert_eq!(app.renderer.textures[&Slot::Main].look, Some((expected, true))); +} + +#[test] +fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { + let (mut app, work, ctx) = fixture(); + let mut jobs = Vec::new(); + for exposure in [1.0, 2.0] { + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.hdr.enabled = true; + edit.light.exposure = exposure; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); + let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); + app.renderer.request(Slot::HdrHistogram, job, 95); + jobs.push(take_job(&work).1); + } + let keys: Vec = jobs.iter().map(|j| j.key).collect(); + let mut jobs = jobs.into_iter(); + let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); + // superseded while the drag goes on, it still lands: the histogram keeps moving + finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); + let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); + assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); + assert!(live.2.hdr.is_some(), "HDR bins"); + finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); + assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); + // only the histogram is kept: no texture + assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); +} + +#[test] +fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, old) = take_job(&work); + let old_result = old.run(); + app.renderer.forget_all(); + let photo = app.session.catalog.photo(PhotoId(1)).unwrap().as_ref().clone(); + app.session = lightcraft_engine::Session::new(); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + app.session.media.file_loader = Some(Arc::new(|_, _| { + let mut image = lightcraft_raster::Rgb32f::new(8, 8); + image.data.fill([0.1, 0.2, 0.9]); + Ok((image, Default::default())) + })); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + let (_, new) = take_job(&work); + assert_eq!(old_result.key, new.key); + assert_ne!(old_result.request_id, new.request_id); + let expected = new.clone().run().rendered.unwrap().image.as_bytes(); + finish(&mut app, &work, &ctx, slot, new.run()); + finish(&mut app, &work, &ctx, slot, old_result); + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected + ); +} + +#[test] +fn late_results_and_errors_cannot_replace_newest_pixels_or_sources() { + for order in [0, 1, 2] { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, old) = take_job(&work); + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 1.0; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + request(&mut app); + let (_, new) = take_job(&work); + let expected = new.clone().run().rendered.unwrap().image.as_bytes(); + if order != 0 { + finish(&mut app, &work, &ctx, slot, new.run()); + // An obsolete source and error must not poison the current successful request. + let mut stale = old.run(); + if order == 2 { + stale.rendered = Err("old failure".into()); + } + finish(&mut app, &work, &ctx, slot, stale); + } else { + finish(&mut app, &work, &ctx, slot, old.run()); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + assert_eq!(app.session.media.source_usage().0, 0); + finish(&mut app, &work, &ctx, slot, new.run()); + } + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected + ); + assert!(app.renderer.failure(slot).is_none()); + } +} + +#[test] +fn undo_to_existing_texture_supersedes_pending_edit() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, first) = take_job(&work); + let key = first.key; + finish(&mut app, &work, &ctx, slot, first.run()); + let original = app.renderer.thumb(PhotoId(1)).unwrap().pixels.clone().unwrap(); + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 1.0; + let undo = app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + request(&mut app); + let (_, edited) = take_job(&work); + app.session.catalog.apply(undo).unwrap(); + request(&mut app); + let (_, restored) = take_job(&work); + assert_eq!(restored.key, key); + finish(&mut app, &work, &ctx, slot, restored.run()); + finish(&mut app, &work, &ctx, slot, edited.run()); + 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(); + 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); + let built = app.renderer.thumb_jobs_built; + assert_eq!(built, 1); + 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); + } + 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()); + 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)); + + // 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); + assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a larger bucket still upgrades"); + 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); + } + 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()); + assert_eq!(app.renderer.textures[&slot].key, larger_key); + assert_eq!(app.renderer.thumb_inputs[&id].1, 512); + assert!(!app.renderer.is_pending(slot)); + + // 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); + } + assert_eq!(app.renderer.thumb_jobs_built, built, "completed larger thumbnails serve smaller cells"); + assert!(work.borrow().jobs.is_empty()); + assert!(!app.renderer.is_pending(slot)); + 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); + + // 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 + // invalidated without this test holding an extra `Arc`. + let remembered = app.renderer.thumb_inputs[&id].0.clone(); + let remembered_ptr = remembered.as_ptr(); + assert!(remembered.upgrade().is_some(), "the remembered Weak points at the live catalog photo"); + assert_eq!(Arc::strong_count(app.session.catalog.photo(id).unwrap()), 1, "the catalog is the only strong owner; the renderer keeps just a Weak"); + + // An edit is a different photo/content key: the completed larger picture must not be + // reused for a smaller cell, and the replacement is requested as before. + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 3.0; + app.session.catalog.apply(Op::SetDevelop { id, settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + let current = app.session.catalog.photo(id).unwrap(); + assert_ne!(Arc::as_ptr(current), remembered_ptr, "make_mut moved the edited photo to a new allocation"); + 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); + assert_eq!(app.renderer.thumb_jobs_built, built + 1, "an edited photo is requested again"); + 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()); + assert_eq!(app.renderer.textures[&slot].key, edited_key); + assert_eq!(app.renderer.thumb_inputs[&id].1, 256); + assert!(!app.renderer.is_pending(slot)); +} + +/// 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] +fn an_evicted_larger_thumbnail_is_rebuilt_for_a_smaller_cell() { + let (mut app, work, ctx) = fixture(); + let id = PhotoId(1); + let slot = Slot::Thumb(id); + crate::panels::grid::request_thumb(&mut app, id, 512, 10); + let (submitted, larger) = take_job(&work); + assert_eq!(submitted, slot); + let larger_key = larger.key; + finish(&mut 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)); + + // An LRU trim drops the pixels; the remembered bucket/key must not stand in for them. + app.renderer.textures.remove(&slot); + let built = app.renderer.thumb_jobs_built; + crate::panels::grid::request_thumb(&mut app, id, 256, 10); + assert_eq!(app.renderer.thumb_jobs_built, built + 1, "an evicted larger thumbnail is rebuilt"); + let (submitted, rebuilt) = take_job(&work); + assert_eq!(submitted, slot); + assert_ne!(rebuilt.key, larger_key); + assert_eq!(app.renderer.pending[&slot].0, rebuilt.key); + assert_eq!(app.renderer.thumb_inputs[&id].1, 256); + let rebuilt_key = rebuilt.key; + finish(&mut app, &work, &ctx, slot, rebuilt.run()); + assert_eq!(app.renderer.textures[&slot].key, rebuilt_key); +} + +#[test] +fn a_coalesced_thumbnail_promotes_once_for_a_higher_priority_caller() { + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let mut app = crate::LightcraftApp::new(s, crate::Services::default()); + app.renderer.pool = JobPool::new(0); // deterministic: nothing starts + let slot = Slot::Thumb(PhotoId(1)); + crate::panels::grid::request_thumb(&mut app, PhotoId(1), 256, 5); + assert_eq!(app.renderer.pending[&slot].1, 5); + let first = app.renderer.pending[&slot].0; + // An on-screen caller at another bucket takes over the off-screen request once... + crate::panels::grid::request_thumb(&mut app, PhotoId(1), 512, 10); + let promoted = app.renderer.pending[&slot]; + assert_eq!(promoted.1, 10); + assert_ne!(promoted.0, first, "the promoted bucket is the one rendered"); + assert_eq!(app.renderer.thumb_jobs_built, 2, "one promotion"); + // ...and the caller it replaced then waits behind that priority instead of replacing it + // back. Equal priorities also keep the request in flight (the print cells' case). + for _ in 0..6 { + crate::panels::grid::request_thumb(&mut app, PhotoId(1), 256, 5); + crate::panels::grid::request_thumb(&mut app, PhotoId(1), 384, 8); + crate::panels::grid::request_thumb(&mut app, PhotoId(1), 512, 10); + } + assert_eq!(app.renderer.pending[&slot], promoted); + assert_eq!(app.renderer.pool.queued(), 1); + assert_eq!(app.renderer.thumb_jobs_built, 2, "a promoted request is not replaced in turn"); +} + +#[test] +fn a_stale_texture_does_not_disable_cross_bucket_coalescing() { + let (mut app, work, ctx) = fixture(); + let id = PhotoId(1); + let slot = Slot::Thumb(id); + crate::panels::grid::request_thumb(&mut app, id, 256, 10); + let (_, first) = take_job(&work); + finish(&mut app, &work, &ctx, slot, first.run()); + let old_key = app.renderer.textures[&slot].key; + + // The edit leaves the old texture displayed until the replacement renders. + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 2.0; + app.session.catalog.apply(Op::SetDevelop { id, settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + let built = app.renderer.thumb_jobs_built; + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.thumb_jobs_built, built + 1); + let (_, edited) = take_job(&work); + let new_key = app.renderer.pending[&slot].0; + assert_ne!(new_key, old_key); + assert_eq!(app.renderer.textures[&slot].key, old_key, "the old picture stays until the replacement arrives"); + + // The stale texture is not the recorded request's picture: other buckets must still keep + // the replacement in flight rather than restarting it every frame. + for _ in 0..12 { + crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(&mut app, id, 512, 10); + } + assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a stale texture must not disable coalescing"); + assert_eq!(app.renderer.pending[&slot].0, new_key); + assert!(work.borrow().jobs.is_empty()); + + // Once the recorded key has arrived, smaller cells reuse it; a larger cell upgrades. + finish(&mut app, &work, &ctx, slot, edited.run()); + assert_eq!(app.renderer.textures[&slot].key, new_key); + crate::panels::grid::request_thumb(&mut app, id, 256, 10); + assert_eq!(app.renderer.thumb_jobs_built, built + 1); + assert!(!app.renderer.is_pending(slot)); + crate::panels::grid::request_thumb(&mut app, id, 512, 10); + assert_eq!(app.renderer.thumb_jobs_built, built + 2); + assert_ne!(app.renderer.pending[&slot].0, new_key, "the larger bucket is requested once"); +} + +#[test] +fn raw_quick_preview_coalesces_across_buckets_without_priority_ping_pong() { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, work, ctx) = fixture_with(s, raw); + app.renderer.pool = JobPool::new(0); // deterministic quick renders via run_inline + let id = PhotoId(1); + let quick = Slot::ThumbQuick(id); + let full = Slot::Thumb(id); + + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + assert_eq!(app.renderer.pending[&quick].1, 6); + let first = app.renderer.pending[&quick].0; + assert!(work.borrow().jobs.is_empty(), "the stand-in renders before the real thumbnail"); + + // The on-screen caller at another bucket promotes the queued stand-in once. + crate::panels::grid::request_thumb(&mut app, id, 512, 10); + let promoted = app.renderer.pending[&quick]; + assert_eq!(promoted.1, 11); + assert_ne!(promoted.0, first); + assert_eq!(app.renderer.thumb_jobs_built, 2); + + // The replaced caller waits for it; neither bucket rebuilds the queued stand-in. + for _ in 0..6 { + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + crate::panels::grid::request_thumb(&mut app, id, 512, 10); + } + assert_eq!(app.renderer.pending[&quick], promoted); + assert_eq!(app.renderer.pool.queued(), 1); + assert_eq!(app.renderer.thumb_jobs_built, 2); + + assert_eq!(app.renderer.pool.run_inline(1), 1); + app.renderer.poll(&ctx, &mut app.session); + assert_eq!(app.renderer.textures[&quick].key, promoted.0); + assert!(!app.renderer.is_pending(quick)); + + // An unedited raw's embedded preview is final. Repeating its bucket must not queue + // a full sensor render behind it, and a smaller cell reuses a preview that arrived + // at a larger bucket instead of asking for another one every frame. + let built = app.renderer.thumb_jobs_built; + crate::panels::grid::request_thumb(&mut app, id, 512, 10); + assert!(!app.renderer.is_pending(full)); + assert!(work.borrow().jobs.is_empty()); + assert_eq!(app.renderer.thumb_jobs_built, built); + for _ in 0..6 { + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + crate::panels::grid::request_thumb(&mut app, id, 256, 10); + } + assert!(!app.renderer.is_pending(full)); + assert!(!app.renderer.is_pending(quick)); + assert!(work.borrow().jobs.is_empty(), "the embedded preview is not rebuilt for a smaller cell"); + assert_eq!(app.renderer.thumb_jobs_built, built); + assert_eq!(app.renderer.textures[&quick].key, promoted.0); + assert_eq!(app.renderer.embedded_thumbs[&id].1, 512, "the embedded preview's bucket is remembered"); +} + +/// The embedded preview is final only at the bucket whose pixels actually arrived. A larger +/// preview that was evicted, or whose job failed at that key, must not make a smaller cell +/// believe it is covered: that cell asks for its own quick render. +#[test] +fn missing_or_failed_larger_embedded_preview_does_not_cover_a_smaller_cell() { + for failed_larger in [false, true] { + let (mut app, _work, ctx) = raw_quick_fixture(); + app.renderer.pool = JobPool::new(0); // deterministic quick renders via run_inline + let id = PhotoId(1); + let quick = Slot::ThumbQuick(id); + crate::panels::grid::request_thumb(&mut app, id, 512, 10); + assert_eq!(app.renderer.pool.run_inline(1), 1); + app.renderer.poll(&ctx, &mut app.session); + assert!(app.renderer.textures.contains_key(&quick)); + assert_eq!(app.renderer.embedded_thumbs[&id].1, 512); + let key = app.renderer.embedded_thumbs[&id].2; + assert_eq!(app.renderer.textures[&quick].key, key); + + // The larger picture is gone: evicted, or its job failed at that key. + app.renderer.textures.remove(&quick); + if failed_larger { + app.renderer.failed.insert(quick, (key, "no embedded preview".into(), app.renderer.catalog_rev, id)); + } + let built = app.renderer.thumb_jobs_built; + crate::panels::grid::request_thumb(&mut app, id, 256, 10); + assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a smaller cell must request its own quick render"); + let smaller_key = app.renderer.pending[&quick].0; + assert_ne!(smaller_key, key); + assert_eq!(app.renderer.pool.run_inline(1), 1); + app.renderer.poll(&ctx, &mut app.session); + assert_eq!(app.renderer.textures[&quick].key, smaller_key); + } +} + +#[test] +fn explicit_clear_repairs_same_key_bad_pixels_and_rejects_old_same_key_job() { + let (mut app, work, ctx) = fixture(); + let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); + let expected = job.clone().run().rendered.unwrap().image; + let (cache, key) = job.cache.clone().unwrap(); + let mut bad = expected.clone(); + bad.data.fill([255, 0, 255, 255]); + cache.put(key, Arc::new(bad.clone())); + request(&mut app); + let (slot, corrupt) = take_job(&work); + finish(&mut app, &work, &ctx, slot, corrupt.run()); + assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels[0].to_array(), [255, 0, 255, 255]); + // An old job can have the SAME render key and stale decoded pixels. + let mut old = job.clone(); + old.source = lightcraft_engine::media::SourceRef::Loaded(Box::new(lightcraft_engine::media::DecodedSource::new( + Arc::new(lightcraft_raster::Rgb32f::new(8, 8)), + None, + ))); + app.renderer.textures.clear(); + app.renderer.request(slot, old, 10); + let (_, old) = take_job(&work); + app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + request(&mut app); + let (_, repaired) = take_job(&work); + assert_eq!(repaired.key, job.key); + finish(&mut app, &work, &ctx, slot, repaired.run()); + finish(&mut app, &work, &ctx, slot, old.run()); + assert_eq!(cache.get(key).unwrap().as_bytes(), expected.as_bytes()); + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected.as_bytes() + ); +} + +#[test] +fn undo_to_embedded_thumb_rejects_late_full_render() { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, work, ctx) = fixture_with(s, raw); + app.renderer.pool = JobPool::new(0); + let id = PhotoId(1); + let quick_slot = Slot::ThumbQuick(id); + let full_slot = Slot::Thumb(id); + let settings_a = app.session.catalog.photo(id).unwrap().develop.clone(); + + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pool.run_inline(1), 1); + app.renderer.poll(&ctx, &mut app.session); + let (key_a, pixels_a) = { + let shown = app.renderer.thumb(id).unwrap(); + assert_eq!(shown.quick, Some(QuickSource::Embedded)); + (shown.key, shown.pixels.as_ref().unwrap().pixels.clone()) + }; + assert!(app.renderer.textures.contains_key(&quick_slot)); + assert!(!app.renderer.textures.contains_key(&full_slot)); + assert!(work.borrow().jobs.is_empty()); + + let mut settings_b = (*settings_a).clone(); + settings_b.light.exposure += 0.5; + app.session.commit("Exposure", Op::SetDevelop { id, settings: Arc::new(settings_b), label: "Exposure".into(), edited: None }).unwrap(); + assert!(!app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + // Manual owns dispatched jobs: hold B until the real Undo command restores A. + let (slot, held_b) = take_job(&work); + assert_eq!(slot, full_slot); + let key_b = held_b.key; + assert_ne!(key_b, key_a); + assert!(app.renderer.is_pending(full_slot)); + + app.run("edit.undo", serde_json::json!({})).unwrap(); + assert_eq!(app.session.catalog.photo(id).unwrap().develop.as_ref(), settings_a.as_ref()); + assert!(app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + { + let restored = app.renderer.thumb(id).unwrap(); + assert_eq!(restored.key, key_a); + assert_eq!(restored.pixels.as_ref().unwrap().pixels, pixels_a); + assert_eq!(restored.quick, Some(QuickSource::Embedded)); + } + assert!(work.borrow().jobs.is_empty(), "restoring embedded-final A needs no full render"); + assert_eq!(app.renderer.pool.queued(), 0); + + let result_b = held_b.run(); + assert_eq!(result_b.key, key_b); + let rendered_b = result_b.rendered.as_ref().expect("held edited thumbnail must render before the stale-result oracle"); + assert_ne!(color_image(&rendered_b.image).pixels, pixels_a, "the held result must be distinguishable from A"); + finish(&mut app, &work, &ctx, slot, result_b); + + let shown = app.renderer.thumb(id).unwrap(); + assert_eq!(shown.key, key_a, "late edited Thumb(B) must not replace restored embedded ThumbQuick(A)"); + assert_eq!(shown.pixels.as_ref().unwrap().pixels, pixels_a); + assert_eq!(shown.quick, Some(QuickSource::Embedded)); + let quick = app.renderer.textures.get(&quick_slot).expect("the correct embedded thumbnail must remain installed"); + assert_eq!(quick.key, key_a); + assert_eq!(quick.quick, Some(QuickSource::Embedded)); + assert_eq!(quick.pixels.as_ref().unwrap().pixels, pixels_a); + assert!(!app.renderer.is_pending(full_slot)); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs new file mode 100644 index 000000000..ccce723fb --- /dev/null +++ b/crates/ui-egui/src/services.rs @@ -0,0 +1,130 @@ +use super::*; + +pub type PickFiles = Box Vec>; + +/// A save dialog: suggested file name → chosen path (`None` = cancelled). +pub type SaveFile = Box Option>; + +pub type WriteFn = Box Result<(), String>>; + +/// A writer other threads can use (background export). +pub type SharedWrite = std::sync::Arc Result<(), String> + Send + Sync>; + +pub type PngEncode = Box Vec>; + +/// A folder chooser (`None` = cancelled). +pub type PickFolder = Box Option>; + +/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux). +pub type RevealFn = Box Result<(), String>>; + +/// Open a URL in the user's browser. +pub type OpenUrlFn = Box Result<(), String>>; + +/// Open a file in an application (`app` = "" for the system's default one). +pub type OpenWithFn = Box Result<(), String>>; + +/// Open several files with one start of an application (`app` as for [`OpenWithFn`]). +pub type OpenAllWithFn = Box Result<(), String>>; + +/// Linux model-folder work: wait for the default opener on a background task. +#[cfg(target_os = "linux")] +pub type OpenModelFolderFn = std::sync::Arc Result<(), String> + Send + Sync>; + +/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: +/// not known). +pub type ScreenProfileFn = Box Option>>; + +/// Whether an external editor can be started (its name as in [`Services::open_with`]); the +/// error says why not and what to do (e.g. PhotoCraft isn't installed). +pub type CheckEditorFn = Box Result<(), String>>; + +/// Something the host does with the session (web: back up or restore the library in browser +/// storage); the work may finish asynchronously. +pub type HostAction = Box Result>; + +/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose +/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it +/// last heard and wakes the UI itself when that changes; it is never polled for changes. +pub type SystemThemeFn = Box Option>; + +/// Platform services injected by the host app (desktop or web). +#[derive(Default)] +pub struct Services { + /// Native file dialogs for commands, shown off the UI thread and answered later + /// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside + /// dialogs; without it (the web, tests) they answer the commands too. + pub picker: Option, + /// Show an open dialog for photos; returns paths. + pub pick_files: Option, + /// Local ONNX model; a manifest must accompany it. + pub pick_denoise_model: Option, + /// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…). + pub pick_model_file: Option, + /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). + pub pick_preset_files: Option, + /// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…). + pub pick_tracklog: Option, + /// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only). + pub pick_display_profile: Option, + /// The profile of the screen showing the window, used while none is chosen in Settings ▸ + /// Display (macOS, whose windows aren't colour-managed: issue #787). + pub screen_profile: Option, + /// Save dialog for an exported `.lcpreset` file. + pub save_preset_file: Option, + /// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…). + pub pick_keyword_list: Option, + /// Save dialog for an exported keyword list (File ▸ Export Keywords…). + pub save_keyword_list: Option, + /// Open dialog for point-curve preset files (`.lccurve`). + pub pick_curve_preset_files: Option, + /// Save dialog for an exported `.lccurve` file. + pub save_curve_preset_file: Option, + /// Open dialog for slideshow template files (`.json`; Settings ▸ Slideshow ▸ Templates ▸ Import…). + pub pick_slideshow_templates: Option, + /// Save dialog for an exported slideshow template (`.json`). + pub save_slideshow_template: Option, + /// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…). + pub pick_print_templates: Option, + /// Save dialog for an exported print template (`.lcprint`). + pub save_print_template: Option, + pub write: Option, + /// Create-new writer (never replaces an existing path), for screenshots of destinations that + /// did not exist when the request was admitted. Required for such captures: falling back to + /// [`Self::write`] would reopen the admission-to-write race. + pub write_new: Option, + /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). + pub write_shared: Option, + /// PNG encoder (the host links an image encoder; the UI crate stays codec-free). + pub png: Option, + /// Show a file in the system file manager (desktop only). + pub reveal: Option, + /// The host's current log file (`/logs/lightcraft.log`), which Help ▸ Open Log + /// Folder reveals (#260). None where no log is kept: the web, `--memory`, `LIGHTCRAFT_NO_PREFS`. + pub log_file: Option, + /// Choose a folder (Settings → General → Open Library…; desktop only). + pub pick_folder: Option, + /// Open a Lightroom Classic `.lrcat` catalog for read-only import. + pub pick_lightroom_catalog: Option, + /// Open a web link in the browser (Help menu, About, Discord button). + pub open_url: Option, + /// Open a file in an external editor (Edit in External Editor; desktop only). + pub open_with: Option, + /// Open several files with one start of the editor (Open Original; desktop only): an editor + /// that keeps one window takes them all. None = [`Services::open_with`] for each. + pub open_all_with: Option, + /// Optional worker-safe model-folder opener alongside `open_with` (Linux desktop only). + #[cfg(target_os = "linux")] + pub open_model_folder: Option, + /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = + /// assume it can). + pub check_editor: Option, + /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the + /// user keeps (web only: there the library lives in browser storage, which the browser may + /// clear; on the desktop it is a folder backed up like any other). + pub backup_library: Option, + /// File ▸ Restore Library from Backup… (web only; keeps the current library). + pub restore_library: Option, + /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. + pub system_theme: Option, +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 032548f6a..c36bcec98 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -54,15 +54,30 @@ pub fn find_bindable(id: &str) -> Option<&'static Bindable> { /// The shortcut `id` has now: the user's choice (`""` = none), else the declared one. An entry /// that doesn't parse (a hand-edited `ui.json`, a modifier-only key saved by an older build) is -/// ignored. +/// ignored. The declared shortcut yields to a saved entry that gives its key to another known +/// command other than its contextual partner. pub fn binding<'a>(keymap: &'a Keymap, id: &str, default: Option<&'a str>) -> Option<&'a str> { match keymap.get(id) { Some(sc) if sc.is_empty() => None, Some(sc) if parse(sc).is_some() => Some(sc.as_str()), - _ => default, + _ => default.filter(|sc| !assigned_elsewhere(keymap, id, sc)), } } +/// Whether the keymap gives the key `sc` to a known command other than `id` and its contextual +/// partner. +fn assigned_elsewhere(keymap: &Keymap, id: &str, sc: &str) -> bool { + let Some(key) = parse(sc) else { return false }; + saved_keys(keymap) + .any(|(other, k)| other != id && k == key && !CONTEXTUAL.iter().any(|(x, y)| (*x == id && *y == other) || (*y == id && *x == other))) +} + +/// The keys the keymap gives to known commands, with each command's id. Entries for commands this +/// build doesn't know and entries that don't parse are ignored. +pub(crate) fn saved_keys(keymap: &Keymap) -> impl Iterator { + keymap.iter().filter(|(id, _)| find_bindable(id).is_some()).filter_map(|(id, sc)| Some((id.as_str(), parse(sc)?))) +} + /// The effective shortcut of command `id` (`None` for unknown commands). pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> { find_bindable(id).and_then(|b| binding(keymap, id, b.default)) @@ -334,11 +349,10 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Space].iter().any(|key| input.key_pressed(*key)) }); } - let chrome = app - .ui - .docking - .as_ref() - .is_some_and(|workspace| craft_ui::docking::DockArea::new(egui::Id::new("lightcraft-docking")).chrome_has_focus(ctx, &workspace.layout)); + let chrome = + app.ui.docking.as_ref().is_some_and(|workspace| { + craft_ui::docking::DockArea::new(egui::Id::unique("lightcraft-docking")).chrome_has_focus(ctx, &workspace.layout) + }); if !chrome { return true; } @@ -349,12 +363,20 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { }) } +/// Dispatch the global shortcut pass. It yields while a dialog, prompt, blocking library window, +/// text field, name edit or shortcut recording owns input, then fires saved command bindings, +/// aliases and fixed photo keys, respecting native menu ownership and open popups. pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) { app.recording_shortcut = None; } - // Dialogs own their keys (including Esc and text editing); background photo actions wait. - if app.ui.dialog.is_some() { + // Dialogs own their keys (including Esc and text editing); background photo actions wait. A + // quit prompt or settings notice owns every key too: its Foreground dim only blocks the + // pointer, so without this a rating or navigation shortcut would still act behind it. The + // blocking library window (error or launch list) owns all app shortcuts for every settled + // frame, not only its shield pass; its own list keys are consumed earlier by + // `library_problem::logic`, and its buttons remain reachable through the control channel. + if app.ui.dialog.is_some() || crate::panels::notices::blocks_input(app) || crate::panels::library_problem::blocks_dialog_input(app) { return; } if app.recording_shortcut.is_none() && app.ui.name_edit.is_none() && crate::panels::person::handle_bulk_key(app, ctx) { @@ -373,8 +395,8 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // The grid navigation keys act on the photos in view, so they wait while the full-screen preview // covers the grid: a key must not move the selection onto photos nobody can see. let grid_navigation = grid && !app.ui.fullscreen; - // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them - let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); + // keys the user gave to a known command: the fixed bindings below (aliases, ratings) yield to them + let taken: Vec<(Modifiers, Key)> = saved_keys(keymap).map(|(_, k)| k).collect(); // an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back, // which also closes dialogs) waits until nothing is open let popup_open = egui::Popup::is_any_open(ctx); @@ -695,661 +717,7 @@ pub(crate) fn library_grid(app: &LightcraftApp) -> bool { } #[cfg(test)] -mod tests { - - fn library() -> crate::headless::Headless { - use lightcraft_catalog::{Op, Photo, PhotoId, Source}; - let mut session = lightcraft_engine::Session::new(); - for id in 1..=4 { - let photo = Photo::new( - PhotoId(id), - Source::File { path: format!("/lightcraft-shortcuts/{id}.jpg") }, - &format!("{id}.jpg"), - "JPEG", - 100, - 100, - "2026-10-08", - ); - session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - } - session.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); - session.execute("library.select", &json!({"ids": [1]})).unwrap(); - let app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); - let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); - h.app.ui.view = crate::state::ViewMode::PhotoGrid; - for _ in 0..3 { - h.step(); - } - h - } - - fn key(h: &mut crate::headless::Headless, key: &str, shift: bool) { - let r = h.request("ui.key", json!({"key": key, "shift": shift}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - } - - /// Press the default Select Only Active Photo chord (the command key on every platform). - fn select_only_active_chord(h: &mut crate::headless::Headless) { - let r = h.request("ui.key", json!({"key": "D", "cmd": true, "shift": true}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - } - - #[test] - fn select_only_active_keeps_the_active_photo_and_the_edit_undo_step() { - use crate::state::ViewMode; - use lightcraft_catalog::PhotoId; - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { - let mut h = library(); - h.app.ui.view = view; - h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); - h.app.session.execute("photo.rate", &json!({"rating": 4})).unwrap(); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - // Narrowing the selection does not replace the last photo-edit undo step. - h.app.session.execute("edit.undo", &json!({})).unwrap(); - for id in 1..=3 { - assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, 0); - } - } - } - - #[test] - fn select_only_active_yields_to_a_focused_text_field() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); - let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2)]); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - } - - #[test] - fn select_only_active_follows_the_keymap() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - let both = vec![PhotoId(1), PhotoId(2)]; - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); - assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "library.selectOnlyActive"), Some("Cmd+Shift+D")); - // unbound: the default chord does nothing - super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", None).unwrap(); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, both); - // rebound: the new key narrows the selection and the old chord still does not - super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", Some("F9")).unwrap(); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, both); - key(&mut h, "F9", false); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); - } - - #[test] - fn select_only_active_without_active_photo_does_not_change_selection() { - let mut h = library(); - // Deselect All keeps the active photo selected, so clear the whole selection state instead. - h.app.session.selection = Default::default(); - assert!(!crate::menus::ui_enabled(&h.app, "library.selectOnlyActive")); - assert!(h.app.run("library.selectOnlyActive", json!({})).is_err()); - select_only_active_chord(&mut h); - assert!(h.app.session.selection.ids.is_empty()); - } - - #[test] - fn library_rating_labels_flags_and_undo() { - use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; - for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::SquareGrid] { - let mut h = library(); - h.app.ui.view = view; - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); - for rating in [1, 2, 3, 4, 5, 0] { - key(&mut h, &rating.to_string(), false); - for id in [1, 2] { - assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, rating); - } - } - for (k, label) in [("6", ColorLabel::Red), ("7", ColorLabel::Yellow), ("8", ColorLabel::Green), ("9", ColorLabel::Blue)] { - key(&mut h, k, false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, Some(label)); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(label)); - } - for (k, flag) in [("P", Flag::Pick), ("X", Flag::Reject), ("U", Flag::None)] { - key(&mut h, k, false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, flag); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, flag); - } - h.app.session.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject); - h.app.session.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); - assert_eq!(h.app.session.active(), Some(PhotoId(1))); - } - } - - #[test] - fn classic_f6_and_f7_toggle_the_filmstrip_and_left_panel() { - let mut h = library(); - let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); - key(&mut h, "F6", false); - assert_eq!(h.app.ui.filmstrip, !filmstrip); - assert_eq!(h.app.ui.left_panel, left); - key(&mut h, "F7", false); - assert_eq!(h.app.ui.left_panel, !left); - key(&mut h, "F6", false); - key(&mut h, "F7", false); - assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); - } - - /// Classic's F8 hides the right panel and shows the same one again; the other panels stay. - #[test] - fn classic_f8_hides_the_right_panel_and_brings_the_same_one_back() { - use crate::state::RightPanel; - let mut h = library(); - h.app.ui.view = crate::state::ViewMode::Detail; - h.app.ui.right = RightPanel::Keywords; - h.settle(std::time::Duration::from_secs(10)); - let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); - key(&mut h, "F8", false); - assert_eq!(h.app.ui.right, RightPanel::None); - assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); - key(&mut h, "F8", false); - assert_eq!(h.app.ui.right, RightPanel::Keywords, "the panel F8 hid comes back"); - // a tool ends when F8 hides it; with nothing hidden before, F8 opens Edit - h.app.ui.right = RightPanel::None; - h.app.ui.right_hidden = RightPanel::None; - key(&mut h, "F8", false); - assert_eq!(h.app.ui.right, RightPanel::Edit); - } - - #[test] - fn shift_culling_keys_advance_exactly_once() { - use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; - for auto in [false, true] { - for (k, rating, label, flag) in [ - ("0", 0, None, Flag::Pick), - ("5", 5, None, Flag::Pick), - ("6", 3, Some(ColorLabel::Red), Flag::Pick), - ("7", 3, Some(ColorLabel::Yellow), Flag::Pick), - ("8", 3, Some(ColorLabel::Green), Flag::Pick), - ("9", 3, Some(ColorLabel::Blue), Flag::Pick), - ("P", 3, None, Flag::Pick), - ("X", 3, None, Flag::Reject), - ("U", 3, None, Flag::None), - ] { - let mut h = library(); - h.app.ui.auto_advance = auto; - h.app.session.execute("photo.rate", &json!({"rating": 3})).unwrap(); - h.app.session.execute("photo.pick", &json!({})).unwrap(); - key(&mut h, k, true); - let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); - assert_eq!(p.rating, rating, "Shift+{k}"); - assert_eq!(p.label, label, "Shift+{k}"); - assert_eq!(p.flag, flag, "Shift+{k}"); - assert_eq!(h.app.session.active(), Some(PhotoId(2)), "Shift+{k}, auto={auto}"); - } - } - let mut h = library(); - h.app.session.execute("library.select", &json!({"ids": [4]})).unwrap(); - key(&mut h, "6", true); - assert_eq!(h.app.session.active(), Some(PhotoId(4)), "last photo stays selected"); - } - - #[test] - fn shift_pick_preserves_presets_outside_the_library_grids() { - let mut h = library(); - h.app.ui.view = crate::state::ViewMode::SquareGrid; - key(&mut h, "P", true); - assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); - assert!(!h.app.ui.presets, "grid Shift+P must not open Presets"); - assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); - h.app.ui.view = crate::state::ViewMode::Detail; - key(&mut h, "P", true); - assert!(h.app.ui.presets); - assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); - } - - #[test] - fn shift_label_targets_only_candidate_in_compare() { - use lightcraft_catalog::{ColorLabel, PhotoId}; - let mut h = library(); - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); - crate::panels::compare::enter_compare(&mut h.app).unwrap(); - key(&mut h, "6", true); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, None); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(ColorLabel::Red)); - assert_eq!(h.app.session.active(), Some(PhotoId(3))); - } - - #[test] - fn plain_culling_keys_auto_advance_once() { - use lightcraft_catalog::PhotoId; - for k in ["0", "5", "6", "9", "P", "X", "U"] { - let mut h = library(); - h.app.ui.auto_advance = true; - key(&mut h, k, false); - assert_eq!(h.app.session.active(), Some(PhotoId(2)), "{k}"); - } - } - - #[test] - fn culling_keys_go_on_from_a_photo_that_leaves_the_filter() { - // #2312: the filter shows one-star photos; taking photo 2's star off makes photo 3 active, once, with or - // without Shift and Auto Advance, and → goes on from there instead of from the first photo - use lightcraft_catalog::PhotoId; - let one_star = || { - let mut h = library(); - h.app.session.execute("library.selectAll", &json!({})).unwrap(); - h.app.session.execute("photo.rate", &json!({"rating": 1})).unwrap(); - h.app.session.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"})).unwrap(); - h.app.session.execute("library.select", &json!({"ids": [2]})).unwrap(); - h - }; - for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::Detail] { - for (shift, auto) in [(false, false), (true, false), (false, true), (true, true)] { - let mut h = one_star(); - h.app.ui.view = view; - h.app.ui.auto_advance = auto; - key(&mut h, "0", shift); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().rating, 0); - assert_eq!(h.app.session.active(), Some(PhotoId(3)), "{view:?} Shift={shift} Auto Advance={auto}"); - key(&mut h, "right", false); - assert_eq!(h.app.session.active(), Some(PhotoId(4)), "{view:?} Shift={shift} Auto Advance={auto}"); - } - } - // the Photo menu with Auto Advance too - let mut h = one_star(); - h.app.ui.auto_advance = true; - crate::menubar::run_item(&mut h.app, "photo.rate", json!({"rating": 0})).unwrap(); - assert_eq!(h.app.session.active(), Some(PhotoId(3))); - } - - #[test] - fn color_label_actions_show_feedback_only_after_success() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - for shift in [false, true] { - for (k, name) in [("6", "Red"), ("7", "Yellow"), ("8", "Green"), ("9", "Blue")] { - h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); - h.app.ui.toast = None; - key(&mut h, k, shift); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str())); - assert_eq!(h.app.ui.toast.as_ref().and_then(|t| t.2), h.app.session.catalog.photo(PhotoId(1)).unwrap().label); - assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 }))); - } - } - // Menus and the Info-panel swatches dispatch the same command, including purple/clear. - h.app.run("photo.label", json!({"label": "purple"})).unwrap(); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label")); - h.app.run("photo.label", json!({"label": "none"})).unwrap(); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared")); - assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.2.is_none()), "clear uses neutral feedback"); - h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap(); - key(&mut h, "6", false); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label")); - // Native menu callbacks run before logic() catches up to the current frame's clock. - let now = h.view.ctx.input(|i| i.time); - h.app.last_time = now - 10.0; - h.app.run("photo.label", json!({"label": "blue"})).unwrap(); - assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.1 > now), "menu feedback must survive an idle gap"); - h.app.ui.toast = None; - assert!(h.app.run("photo.label", json!({"label": "invalid"})).is_err()); - assert!(h.app.ui.toast.is_none(), "failed actions must not announce success"); - } - - #[test] - fn shifted_number_punctuation_rates_and_advances() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - // A real winit event on a layout with Shift+1 = !, rather than ui.key's logical Num1. - for pressed in [true, false] { - h.app.synthetic.push(egui::Event::Key { - key: Key::Exclamationmark, - physical_key: Some(Key::Num1), - pressed, - repeat: false, - modifiers: Modifiers::SHIFT, - }); - } - h.step(); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 1); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - } - - #[test] - fn library_keys_yield_to_search_and_crop_context() { - use lightcraft_catalog::{Flag, PhotoId}; - let mut h = library(); - let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - for k in ["5", "6", "P", "X", "U"] { - key(&mut h, k, false); - key(&mut h, k, true); - } - let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); - assert_eq!(p.rating, 0); - assert_eq!(p.label, None); - assert_eq!(p.flag, Flag::None); - assert_eq!(h.app.session.active(), Some(PhotoId(1))); - - let mut h = library(); - h.app.ui.view = crate::state::ViewMode::Detail; - h.app.ui.right = crate::state::RightPanel::Crop; - key(&mut h, "X", false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); - } - - #[test] - fn delete_means_the_backspace_key() { - assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace))); - assert_eq!(parse("Cmd+Delete"), Some((Modifiers::COMMAND, Key::Backspace))); - } - use super::*; - - #[test] - fn parses_shortcuts() { - let (m, k) = parse("Cmd+Shift+Z").unwrap(); - assert!(m.command && m.shift && !m.alt); - assert_eq!(k, Key::Z); - assert_eq!(parse("Right").unwrap().1, Key::ArrowRight); - assert_eq!(parse("\\").unwrap().1, Key::Backslash); - // every declared shortcut parses - for (id, _, sc, _) in crate::menus::ui_commands() { - if let Some(sc) = sc { - assert!(parse(sc).is_some(), "{id}: {sc}"); - } - } - for c in lightcraft_engine::command_specs() { - if let Some(sc) = c.shortcut { - assert!(parse(sc).is_some(), "{}: {sc}", c.id); - } - } - } - - #[test] - fn aliases_parse_and_target_existing_commands() { - let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect(); - for (sc, id, params) in ALIASES { - assert!(parse(sc).is_some(), "{id}: {sc}"); - assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); - assert!(serde_json::from_str::(params).is_ok(), "alias {sc}: bad params"); - } - } - - #[test] - fn key_presses_format_to_shortcuts_that_parse_back() { - for k in Key::ALL { - for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] { - if let Some(sc) = format(m, *k) { - let want = if *k == Key::Delete { Key::Backspace } else { *k }; - assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}"); - } - } - } - assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K")); - assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/")); - assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete")); - } - - #[test] - fn assigning_a_key_moves_it_and_reset_restores_it() { - let mut keymap = Keymap::new(); - assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); - // D belongs to Detail: Survey takes it, Detail loses it - let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap(); - assert_eq!(lost, vec!["view.detail"]); - assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D")); - assert_eq!(shortcut_of(&keymap, "view.detail"), None); - // restoring Detail takes D back - let lost = reset(&mut keymap, "view.detail").unwrap(); - assert_eq!(lost, vec!["view.survey"]); - assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); - reset(&mut keymap, "view.survey").unwrap(); - assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}"); - // a command without a declared shortcut can get one, and lose it again - assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err(); - assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1")); - assign(&mut keymap, "view.photoGrid", None).unwrap(); - assert!(keymap.is_empty()); - } - - #[test] - fn bad_assignments_are_errors() { - let mut keymap = Keymap::new(); - assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err()); - assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err()); - assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err()); - assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err()); - assert!(keymap.is_empty()); - } - - /// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic. - #[test] - fn junk_keymap_entries_mean_no_shortcut() { - let mut keymap = Keymap::new(); - keymap.insert("view.survey".into(), "Hyper+☃".into()); - keymap.insert("no.suchCommand".into(), "K".into()); - keymap.insert("view.detail".into(), "Cmd+SuperLeft".into()); - assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); - assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); - assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey")); - assert_eq!(parse("Hyper+K"), None); - } - - /// Contextual partners keep sharing their key when one is reassigned. - #[test] - fn contextual_partners_are_not_conflicts() { - let keymap = Keymap::new(); - assert!(conflicts(&keymap, "photo.reject", "X").is_empty()); - } - - /// No key fires two different actions (a UI command may shadow the engine command it wraps). - #[test] - fn no_conflicting_bindings() { - let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); - for (id, _, sc, _) in crate::menus::ui_commands() { - if let Some(k) = sc.and_then(parse) { - ui.push((k, id.to_string())); - } - } - for (sc, id, _) in ALIASES { - ui.push((parse(sc).unwrap(), format!("alias {id}"))); - } - let mut engine: Vec<((Modifiers, Key), &str)> = Vec::new(); - for c in lightcraft_engine::command_specs() { - if let Some(k) = c.shortcut.and_then(parse) { - engine.push((k, c.id)); - } - } - for (sc, id, _) in ALIASES { - let k = parse(sc).unwrap(); - assert!(!engine.iter().any(|(k2, _)| *k2 == k), "alias {sc} ({id}) shadows an engine shortcut"); - } - for (i, (k, a)) in ui.iter().enumerate() { - for (k2, b) in &ui[i + 1..] { - let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); - assert!(k != k2 || contextual, "{a} and {b} share a key"); - } - } - for (i, (k, a)) in engine.iter().enumerate() { - for (k2, b) in &engine[i + 1..] { - let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); - assert!(k != k2 || contextual, "{a} and {b} share a key"); - } - } - } - mod issue1771 { - use super::*; - use crate::state::{Dialog, ViewMode}; - use lightcraft_catalog::PhotoId; - - fn grid(view: ViewMode) -> crate::headless::Headless { - let mut h = library(); - h.app.ui.view = view; - h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); - for _ in 0..3 { - h.step(); - } - h - } - - #[test] - fn issue1771_photo_grid_enter_opens_active_preserving_multiselection() { - opens(ViewMode::PhotoGrid); - } - - #[test] - fn issue1771_square_grid_enter_opens_active_preserving_multiselection() { - opens(ViewMode::SquareGrid); - } - - fn opens(view: ViewMode) { - let mut h = grid(view); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Detail, "Enter from {view:?}"); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Detail, "second Enter remains in Detail"); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - } - - #[test] - fn issue1771_no_selection_and_modifier_chords_do_not_open() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - for modifiers in [json!({"shift":true}), json!({"cmd":true}), json!({"alt":true}), json!({"ctrl":true})] { - let mut args = modifiers; - args["key"] = json!("Enter"); - assert_eq!(h.request("ui.key", args, std::time::Duration::from_secs(20))["ok"], true); - assert_eq!(h.app.ui.view, view); - } - h.app.session.selection = Default::default(); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view); - assert!(h.app.session.selection.ids.is_empty()); - assert_eq!(h.app.session.active(), None); - } - } - - #[test] - fn issue1771_search_dialog_and_shortcut_recording_keep_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - assert_eq!(h.request("ui.clickWidget", json!({"id":"field:search"}), std::time::Duration::from_secs(20))["ok"], true); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "focused search"); - h.app.run("app.shortcuts", json!({})).unwrap(); - h.step(); - h.step(); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "shortcut dialog"); - assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); - h.app.recording_shortcut = Some("view.survey".into()); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "recording owns Enter"); - assert_eq!(super::super::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Enter")); - } - } - - #[test] - fn issue1771_custom_binding_and_native_menu_keep_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - h.app.run("app.setShortcut", json!({"id":"view.survey", "shortcut":"Enter"})).unwrap(); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Survey, "custom binding wins"); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids.len(), 3); - let mut h = grid(view); - h.app.native_shortcuts.insert("Enter".into()); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "native menu owns Enter"); - } - } - - #[test] - fn issue1771_popup_keeps_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - let ctx = egui::Context::default(); - let mut output = ctx.run_ui( - egui::RawInput { - events: vec![egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }], - ..Default::default() - }, - |ui| { - let ctx = ui.ctx(); - egui::Popup::open_id(ctx, egui::Id::new("issue1771-menu")); - assert!(egui::Popup::is_any_open(ctx)); - super::super::handle(&mut h.app, ctx); - }, - ); - output.textures_delta.clear(); - assert_eq!(h.app.ui.view, view); - } - } - - #[test] - fn issue1771_detail_enter_keeps_active_and_multiselection() { - let mut h = grid(ViewMode::Detail); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Detail); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); - } - - #[test] - fn issue1771_open_file_menu_keeps_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - assert_eq!(h.request("ui.clickWidget", json!({"id":"menu:File"}), std::time::Duration::from_secs(20))["ok"], true); - h.step(); - assert!(egui::Popup::is_any_open(&h.view.ctx), "File menu open before Enter"); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids.len(), 3); - } - } - - #[test] - fn issue1771_repeat_event_opens_once_and_detail_keeps_selection() { - let mut h = grid(ViewMode::PhotoGrid); - h.events.push(egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: true, - modifiers: egui::Modifiers::NONE, - }); - h.step(); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::Detail); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids.len(), 3); - h.events.push(egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: true, - modifiers: egui::Modifiers::NONE, - }); - h.step(); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::Detail); - assert_eq!(h.app.session.selection.ids.len(), 3); - } - } -} +mod tests; #[cfg(test)] #[path = "shortcuts/tests_relative_ratings.rs"] diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs new file mode 100644 index 000000000..a6fbb1333 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -0,0 +1,702 @@ +fn library() -> crate::headless::Headless { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut session = lightcraft_engine::Session::new(); + for id in 1..=4 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-shortcuts/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 100, + 100, + "2026-10-08", + ); + session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + session.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); + session.execute("library.select", &json!({"ids": [1]})).unwrap(); + let app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.view = crate::state::ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + h +} + +fn key(h: &mut crate::headless::Headless, key: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": key, "shift": shift}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Press the default Select Only Active Photo chord (the command key on every platform). +fn select_only_active_chord(h: &mut crate::headless::Headless) { + let r = h.request("ui.key", json!({"key": "D", "cmd": true, "shift": true}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); +} + +#[test] +fn select_only_active_keeps_the_active_photo_and_the_edit_undo_step() { + use crate::state::ViewMode; + use lightcraft_catalog::PhotoId; + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); + h.app.session.execute("photo.rate", &json!({"rating": 4})).unwrap(); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + // Narrowing the selection does not replace the last photo-edit undo step. + h.app.session.execute("edit.undo", &json!({})).unwrap(); + for id in 1..=3 { + assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, 0); + } + } +} + +#[test] +fn select_only_active_yields_to_a_focused_text_field() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2)]); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); +} + +#[test] +fn select_only_active_follows_the_keymap() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + let both = vec![PhotoId(1), PhotoId(2)]; + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "library.selectOnlyActive"), Some("Cmd+Shift+D")); + // unbound: the default chord does nothing + super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", None).unwrap(); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, both); + // rebound: the new key narrows the selection and the old chord still does not + super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", Some("F9")).unwrap(); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, both); + key(&mut h, "F9", false); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); +} + +#[test] +fn select_only_active_without_active_photo_does_not_change_selection() { + let mut h = library(); + // Deselect All keeps the active photo selected, so clear the whole selection state instead. + h.app.session.selection = Default::default(); + assert!(!crate::menus::ui_enabled(&h.app, "library.selectOnlyActive")); + assert!(h.app.run("library.selectOnlyActive", json!({})).is_err()); + select_only_active_chord(&mut h); + assert!(h.app.session.selection.ids.is_empty()); +} + +#[test] +fn library_rating_labels_flags_and_undo() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::SquareGrid] { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + for rating in [1, 2, 3, 4, 5, 0] { + key(&mut h, &rating.to_string(), false); + for id in [1, 2] { + assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, rating); + } + } + for (k, label) in [("6", ColorLabel::Red), ("7", ColorLabel::Yellow), ("8", ColorLabel::Green), ("9", ColorLabel::Blue)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, Some(label)); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(label)); + } + for (k, flag) in [("P", Flag::Pick), ("X", Flag::Reject), ("U", Flag::None)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, flag); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, flag); + } + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + } +} + +/// A saved keymap entry that gives a key to one command (kept from before another command +/// declared that key) wins: the press runs that command alone, and the other command's declared +/// key comes back once the entry is gone. +#[test] +fn a_saved_key_wins_over_another_commands_declared_key() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None); + assert_eq!(super::conflicts(&h.app.ui.settings.keymap, "view.survey", "P"), vec!["photo.increaseRating"]); + key(&mut h, "P", false); + let photo = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(photo.rating, 1, "the saved key raised the rating"); + assert_eq!(photo.flag, Flag::None, "Flag as Pick's declared key yielded"); + // with the entry gone, P picks again + h.app.ui.settings.keymap.remove("photo.increaseRating"); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P")); + key(&mut h, "P", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick); + // a contextual partner's saved key does not silence the other side + h.app.ui.settings.keymap.insert("crop.rotateAspect".into(), "X".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.reject"), Some("X")); + // nor does an entry for a command this build doesn't know + h.app.ui.settings.keymap.insert("no.suchCommand".into(), "U".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.unflag"), Some("U")); +} + +/// Keymap entries for commands this build doesn't know take no key: an alias, a rating key and +/// a declared shortcut on the same keys keep working. +#[test] +fn junk_keymap_entries_leave_aliases_ratings_and_declared_keys() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + for (id, sc) in [("no.suchCommand", "Shift+X"), ("no.otherCommand", "5"), ("no.thirdCommand", "P")] { + h.app.ui.settings.keymap.insert(id.into(), sc.into()); + } + key(&mut h, "5", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 5, "5 still rates"); + key(&mut h, "P", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick, "P still picks"); + key(&mut h, "X", true); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject, "Shift+X still rejects"); +} + +#[test] +fn classic_f6_and_f7_toggle_the_filmstrip_and_left_panel() { + let mut h = library(); + let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); + key(&mut h, "F6", false); + assert_eq!(h.app.ui.filmstrip, !filmstrip); + assert_eq!(h.app.ui.left_panel, left); + key(&mut h, "F7", false); + assert_eq!(h.app.ui.left_panel, !left); + key(&mut h, "F6", false); + key(&mut h, "F7", false); + assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); +} + +/// Classic's F8 hides the right panel and shows the same one again; the other panels stay. +#[test] +fn classic_f8_hides_the_right_panel_and_brings_the_same_one_back() { + use crate::state::RightPanel; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Keywords; + h.settle(std::time::Duration::from_secs(10)); + let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::None); + assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Keywords, "the panel F8 hid comes back"); + // a tool ends when F8 hides it; with nothing hidden before, F8 opens Edit + h.app.ui.right = RightPanel::None; + h.app.ui.right_hidden = RightPanel::None; + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Edit); +} + +/// F8 on a minimized Tools window opens it again in the mode it had. +#[test] +fn classic_f8_shows_a_minimized_tools_window_and_keeps_its_mode() { + use crate::{docking::Panel, state::RightPanel}; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Edit; + h.settle(std::time::Duration::from_secs(10)); + h.app.run("ui.dock", json!({"operation": "float", "panel": "tools", "rect": [400, 100, 320, 500]})).unwrap(); + h.app.run("ui.dock", json!({"action": {"SetMinimized": {"panel": "tools", "minimized": true}}})).unwrap(); + h.app.ui.right = RightPanel::Crop; + h.step(); + assert!(!crate::docking::exposed(&h.app, Panel::Tools)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Crop); + assert!(crate::docking::exposed(&h.app, Panel::Tools), "the window opens"); +} + +#[test] +fn shift_culling_keys_advance_exactly_once() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for auto in [false, true] { + for (k, rating, label, flag) in [ + ("0", 0, None, Flag::Pick), + ("5", 5, None, Flag::Pick), + ("6", 3, Some(ColorLabel::Red), Flag::Pick), + ("7", 3, Some(ColorLabel::Yellow), Flag::Pick), + ("8", 3, Some(ColorLabel::Green), Flag::Pick), + ("9", 3, Some(ColorLabel::Blue), Flag::Pick), + ("P", 3, None, Flag::Pick), + ("X", 3, None, Flag::Reject), + ("U", 3, None, Flag::None), + ] { + let mut h = library(); + h.app.ui.auto_advance = auto; + h.app.session.execute("photo.rate", &json!({"rating": 3})).unwrap(); + h.app.session.execute("photo.pick", &json!({})).unwrap(); + key(&mut h, k, true); + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, rating, "Shift+{k}"); + assert_eq!(p.label, label, "Shift+{k}"); + assert_eq!(p.flag, flag, "Shift+{k}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "Shift+{k}, auto={auto}"); + } + } + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [4]})).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "last photo stays selected"); +} + +#[test] +fn shift_pick_preserves_presets_outside_the_library_grids() { + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::SquareGrid; + key(&mut h, "P", true); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); + assert!(!h.app.ui.presets, "grid Shift+P must not open Presets"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); + h.app.ui.view = crate::state::ViewMode::Detail; + key(&mut h, "P", true); + assert!(h.app.ui.presets); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); +} + +#[test] +fn shift_label_targets_only_candidate_in_compare() { + use lightcraft_catalog::{ColorLabel, PhotoId}; + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + crate::panels::compare::enter_compare(&mut h.app).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, None); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(ColorLabel::Red)); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); +} + +#[test] +fn plain_culling_keys_auto_advance_once() { + use lightcraft_catalog::PhotoId; + for k in ["0", "5", "6", "9", "P", "X", "U"] { + let mut h = library(); + h.app.ui.auto_advance = true; + key(&mut h, k, false); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "{k}"); + } +} + +#[test] +fn culling_keys_go_on_from_a_photo_that_leaves_the_filter() { + // #2312: the filter shows one-star photos; taking photo 2's star off makes photo 3 active, once, with or + // without Shift and Auto Advance, and → goes on from there instead of from the first photo + use lightcraft_catalog::PhotoId; + let one_star = || { + let mut h = library(); + h.app.session.execute("library.selectAll", &json!({})).unwrap(); + h.app.session.execute("photo.rate", &json!({"rating": 1})).unwrap(); + h.app.session.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"})).unwrap(); + h.app.session.execute("library.select", &json!({"ids": [2]})).unwrap(); + h + }; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::Detail] { + for (shift, auto) in [(false, false), (true, false), (false, true), (true, true)] { + let mut h = one_star(); + h.app.ui.view = view; + h.app.ui.auto_advance = auto; + key(&mut h, "0", shift); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().rating, 0); + assert_eq!(h.app.session.active(), Some(PhotoId(3)), "{view:?} Shift={shift} Auto Advance={auto}"); + key(&mut h, "right", false); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "{view:?} Shift={shift} Auto Advance={auto}"); + } + } + // the Photo menu with Auto Advance too + let mut h = one_star(); + h.app.ui.auto_advance = true; + crate::menubar::run_item(&mut h.app, "photo.rate", json!({"rating": 0})).unwrap(); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); +} + +#[test] +fn color_label_actions_show_feedback_only_after_success() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + for shift in [false, true] { + for (k, name) in [("6", "Red"), ("7", "Yellow"), ("8", "Green"), ("9", "Blue")] { + h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); + h.app.ui.toast = None; + key(&mut h, k, shift); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str())); + assert_eq!(h.app.ui.toast.as_ref().and_then(|t| t.2), h.app.session.catalog.photo(PhotoId(1)).unwrap().label); + assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 }))); + } + } + // Menus and the Info-panel swatches dispatch the same command, including purple/clear. + h.app.run("photo.label", json!({"label": "purple"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label")); + h.app.run("photo.label", json!({"label": "none"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared")); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.2.is_none()), "clear uses neutral feedback"); + h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap(); + key(&mut h, "6", false); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label")); + // Native menu callbacks run before logic() catches up to the current frame's clock. + let now = h.view.ctx.input(|i| i.time); + h.app.last_time = now - 10.0; + h.app.run("photo.label", json!({"label": "blue"})).unwrap(); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.1 > now), "menu feedback must survive an idle gap"); + h.app.ui.toast = None; + assert!(h.app.run("photo.label", json!({"label": "invalid"})).is_err()); + assert!(h.app.ui.toast.is_none(), "failed actions must not announce success"); +} + +#[test] +fn shifted_number_punctuation_rates_and_advances() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + // A real winit event on a layout with Shift+1 = !, rather than ui.key's logical Num1. + for pressed in [true, false] { + h.app.synthetic.push(egui::Event::Key { + key: Key::Exclamationmark, + physical_key: Some(Key::Num1), + pressed, + repeat: false, + modifiers: Modifiers::SHIFT, + }); + } + h.step(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 1); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); +} + +#[test] +fn library_keys_yield_to_search_and_crop_context() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + for k in ["5", "6", "P", "X", "U"] { + key(&mut h, k, false); + key(&mut h, k, true); + } + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, 0); + assert_eq!(p.label, None); + assert_eq!(p.flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = crate::state::RightPanel::Crop; + key(&mut h, "X", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); +} + +#[test] +fn delete_means_the_backspace_key() { + assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace))); + assert_eq!(parse("Cmd+Delete"), Some((Modifiers::COMMAND, Key::Backspace))); +} +use super::*; + +#[test] +fn parses_shortcuts() { + let (m, k) = parse("Cmd+Shift+Z").unwrap(); + assert!(m.command && m.shift && !m.alt); + assert_eq!(k, Key::Z); + assert_eq!(parse("Right").unwrap().1, Key::ArrowRight); + assert_eq!(parse("\\").unwrap().1, Key::Backslash); + // every declared shortcut parses + for (id, _, sc, _) in crate::menus::ui_commands() { + if let Some(sc) = sc { + assert!(parse(sc).is_some(), "{id}: {sc}"); + } + } + for c in lightcraft_engine::command_specs() { + if let Some(sc) = c.shortcut { + assert!(parse(sc).is_some(), "{}: {sc}", c.id); + } + } +} + +#[test] +fn aliases_parse_and_target_existing_commands() { + let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect(); + for (sc, id, params) in ALIASES { + assert!(parse(sc).is_some(), "{id}: {sc}"); + assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); + assert!(serde_json::from_str::(params).is_ok(), "alias {sc}: bad params"); + } +} + +#[test] +fn key_presses_format_to_shortcuts_that_parse_back() { + for k in Key::ALL { + for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] { + if let Some(sc) = format(m, *k) { + let want = if *k == Key::Delete { Key::Backspace } else { *k }; + assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}"); + } + } + } + assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K")); + assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/")); + assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete")); +} + +#[test] +fn assigning_a_key_moves_it_and_reset_restores_it() { + let mut keymap = Keymap::new(); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + // D belongs to Detail: Survey takes it, Detail loses it + let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap(); + assert_eq!(lost, vec!["view.detail"]); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D")); + assert_eq!(shortcut_of(&keymap, "view.detail"), None); + // restoring Detail takes D back + let lost = reset(&mut keymap, "view.detail").unwrap(); + assert_eq!(lost, vec!["view.survey"]); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + reset(&mut keymap, "view.survey").unwrap(); + assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}"); + // a command without a declared shortcut can get one, and lose it again + assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err(); + assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1")); + assign(&mut keymap, "view.photoGrid", None).unwrap(); + assert!(keymap.is_empty()); +} + +#[test] +fn bad_assignments_are_errors() { + let mut keymap = Keymap::new(); + assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err()); + assert!(keymap.is_empty()); +} + +/// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic. +#[test] +fn junk_keymap_entries_mean_no_shortcut() { + let mut keymap = Keymap::new(); + keymap.insert("view.survey".into(), "Hyper+☃".into()); + keymap.insert("no.suchCommand".into(), "K".into()); + keymap.insert("view.detail".into(), "Cmd+SuperLeft".into()); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey")); + assert_eq!(parse("Hyper+K"), None); +} + +/// Contextual partners keep sharing their key when one is reassigned. +#[test] +fn contextual_partners_are_not_conflicts() { + let keymap = Keymap::new(); + assert!(conflicts(&keymap, "photo.reject", "X").is_empty()); +} + +/// No key fires two different actions (a UI command may shadow the engine command it wraps). +#[test] +fn no_conflicting_bindings() { + let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); + for (id, _, sc, _) in crate::menus::ui_commands() { + if let Some(k) = sc.and_then(parse) { + ui.push((k, id.to_string())); + } + } + for (sc, id, _) in ALIASES { + ui.push((parse(sc).unwrap(), format!("alias {id}"))); + } + let mut engine: Vec<((Modifiers, Key), &str)> = Vec::new(); + for c in lightcraft_engine::command_specs() { + if let Some(k) = c.shortcut.and_then(parse) { + engine.push((k, c.id)); + } + } + for (sc, id, _) in ALIASES { + let k = parse(sc).unwrap(); + assert!(!engine.iter().any(|(k2, _)| *k2 == k), "alias {sc} ({id}) shadows an engine shortcut"); + } + for (i, (k, a)) in ui.iter().enumerate() { + for (k2, b) in &ui[i + 1..] { + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); + } + } + for (i, (k, a)) in engine.iter().enumerate() { + for (k2, b) in &engine[i + 1..] { + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); + } + } +} +mod issue1771 { + use super::*; + use crate::state::{Dialog, ViewMode}; + use lightcraft_catalog::PhotoId; + + fn grid(view: ViewMode) -> crate::headless::Headless { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); + for _ in 0..3 { + h.step(); + } + h + } + + #[test] + fn issue1771_photo_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::PhotoGrid); + } + + #[test] + fn issue1771_square_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::SquareGrid); + } + + fn opens(view: ViewMode) { + let mut h = grid(view); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "Enter from {view:?}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "second Enter remains in Detail"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + } + + #[test] + fn issue1771_no_selection_and_modifier_chords_do_not_open() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + for modifiers in [json!({"shift":true}), json!({"cmd":true}), json!({"alt":true}), json!({"ctrl":true})] { + let mut args = modifiers; + args["key"] = json!("Enter"); + assert_eq!(h.request("ui.key", args, std::time::Duration::from_secs(20))["ok"], true); + assert_eq!(h.app.ui.view, view); + } + h.app.session.selection = Default::default(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert!(h.app.session.selection.ids.is_empty()); + assert_eq!(h.app.session.active(), None); + } + } + + #[test] + fn issue1771_search_dialog_and_shortcut_recording_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"field:search"}), std::time::Duration::from_secs(20))["ok"], true); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "focused search"); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "shortcut dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + h.app.recording_shortcut = Some("view.survey".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "recording owns Enter"); + assert_eq!(super::super::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Enter")); + } + } + + #[test] + fn issue1771_custom_binding_and_native_menu_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + h.app.run("app.setShortcut", json!({"id":"view.survey", "shortcut":"Enter"})).unwrap(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Survey, "custom binding wins"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + let mut h = grid(view); + h.app.native_shortcuts.insert("Enter".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "native menu owns Enter"); + } + } + + #[test] + fn issue1771_popup_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| { + let ctx = ui.ctx(); + egui::Popup::open_id(ctx, egui::Id::unique("issue1771-menu")); + assert!(egui::Popup::is_any_open(ctx)); + super::super::handle(&mut h.app, ctx); + }, + ); + output.textures_delta.clear(); + assert_eq!(h.app.ui.view, view); + } + } + + #[test] + fn issue1771_detail_enter_keeps_active_and_multiselection() { + let mut h = grid(ViewMode::Detail); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + } + + #[test] + fn issue1771_open_file_menu_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"menu:File"}), std::time::Duration::from_secs(20))["ok"], true); + h.step(); + assert!(egui::Popup::is_any_open(&h.view.ctx), "File menu open before Enter"); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + } + } + + #[test] + fn issue1771_repeat_event_opens_once_and_detail_keeps_selection() { + let mut h = grid(ViewMode::PhotoGrid); + h.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + h.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.selection.ids.len(), 3); + } +} diff --git a/crates/ui-egui/src/shortcuts/tests_filter_bar_key.rs b/crates/ui-egui/src/shortcuts/tests_filter_bar_key.rs index 41d0b1c2d..80a904c0e 100644 --- a/crates/ui-egui/src/shortcuts/tests_filter_bar_key.rs +++ b/crates/ui-egui/src/shortcuts/tests_filter_bar_key.rs @@ -36,7 +36,7 @@ fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option, text: O egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, |ui| { if let Some(text) = text.as_deref_mut() { - ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("filter-bar-key-search"))).request_focus(); + ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("filter-bar-key-search"))).request_focus(); } super::handle(app, ui.ctx()); }, diff --git a/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs index d4e6deec5..109dca607 100644 --- a/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs +++ b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs @@ -37,7 +37,7 @@ fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<(Key, Modifie egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, |ui| { if let Some(text) = text.as_deref_mut() { - ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("flag-cycle-search"))).request_focus(); + ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("flag-cycle-search"))).request_focus(); } super::handle(app, ui.ctx()); }, diff --git a/crates/ui-egui/src/shortcuts/tests_relative_ratings.rs b/crates/ui-egui/src/shortcuts/tests_relative_ratings.rs index 8ca2adc1a..94ec5098b 100644 --- a/crates/ui-egui/src/shortcuts/tests_relative_ratings.rs +++ b/crates/ui-egui/src/shortcuts/tests_relative_ratings.rs @@ -44,7 +44,7 @@ fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option, text: O egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, |ui| { if let Some(text) = text.as_deref_mut() { - ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("relative-rating-search"))).request_focus(); + ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("relative-rating-search"))).request_focus(); } super::handle(app, ui.ctx()); }, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 53d5b8718..38b50902c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -2,743 +2,21 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ViewMode { - /// Justified rows ("Photo Grid"). - #[default] - PhotoGrid, - SquareGrid, - Detail, - /// Two photos side by side (select | candidate), synced zoom. - Compare, - /// The selected photos tiled. - Survey, - /// A reference photo (left, fixed) beside the active photo (right, being edited). - Reference, - /// A card per person named on faces (close-up, name, photo count). - People, -} - -/// The right-hand tool/panel shown next to the tool strip. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RightPanel { - #[default] - None, - Edit, - Crop, - Remove, - Masking, - RedEye, - Versions, - Activity, - Keywords, - Info, - /// The profile browser (part of Edit). - Profiles, -} - -impl RightPanel { - pub fn is_edit_tool(self) -> bool { - matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Zoom { - #[default] - Fit, - Fill, - /// 100 % = one image pixel per physical screen pixel. - Percent(f32), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BeforeAfter { - #[default] - Off, - /// Hold `\`: show the original. - Original, - SideBySide, - Split, - /// Before above after. - TopBottom, - /// One image split horizontally: before above the line. - SplitTopBottom, -} - -impl BeforeAfter { - /// One of the Before / After layouts (two views or a split line), which the bottom bar's - /// Before/After (Y) button stands for. Show Original (`\`) is a view of its own, not a layout. - pub fn is_layout(self) -> bool { - !matches!(self, BeforeAfter::Off | BeforeAfter::Original) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CropOverlay { - #[default] - Thirds, - Grid, - Golden, - Diagonal, - Triangle, - Spiral, - None, -} - -/// Tool Overlay: when the retouching tools' pins (Remove / Heal / Clone spots, mask pins) show on -/// the photo. Shift+H steps through the modes in this order (`view.toolOverlay`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ToolOverlay { - /// While the pointer is over the photo (hidden while a new spot or brush stroke is painted). - #[default] - Auto, - Always, - /// Only the selected spot's or mask's. - Selected, - /// None; the tool still works (a click still adds a spot). - Never, -} - -impl ToolOverlay { - pub const ALL: [ToolOverlay; 4] = [ToolOverlay::Auto, ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never]; - - /// The id used by `view.toolOverlay {mode}` and the saved settings. - pub fn name(self) -> &'static str { - match self { - ToolOverlay::Auto => "auto", - ToolOverlay::Always => "always", - ToolOverlay::Selected => "selected", - ToolOverlay::Never => "never", - } - } - - pub fn parse(s: &str) -> Option { - ToolOverlay::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s.trim())) - } - - /// Menu label. - pub fn label(self) -> &'static str { - match self { - ToolOverlay::Auto => "Auto", - ToolOverlay::Always => "Always", - ToolOverlay::Selected => "Selected", - ToolOverlay::Never => "Never", - } - } - - /// The mode Shift+H steps to. - pub fn next(self) -> ToolOverlay { - match self { - ToolOverlay::Auto => ToolOverlay::Always, - ToolOverlay::Always => ToolOverlay::Selected, - ToolOverlay::Selected => ToolOverlay::Never, - ToolOverlay::Never => ToolOverlay::Auto, - } - } - - /// Whether a pin shows: `selected` = its spot or mask is the selected one, `live` = the pointer - /// is over the photo (or a pin is being dragged), which is what Auto waits for. - pub fn shows(self, selected: bool, live: bool) -> bool { - match self { - ToolOverlay::Auto => live, - ToolOverlay::Always => true, - ToolOverlay::Selected => selected, - ToolOverlay::Never => false, - } - } -} - -/// Which view the app opens in. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StartupView { - /// Whatever was showing at quit. - #[default] - Last, - Grid, - Detail, -} - -/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library` -/// and `LIGHTCRAFT_LIBRARY` come first either way. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LaunchLibrary { - /// The library open when the app last quit (holding Alt/Option as the window appears asks once). - #[default] - Last, - /// A window lists the recent libraries to choose from. - Ask, -} - -/// When grid cells show their rating / flag / edited badges. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum GridBadges { - /// On hover, on the selection, and on rated or flagged photos. - #[default] - Auto, - Always, - Never, -} - -/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum InfoOverlay { - #[default] - Off, - /// File name, capture date and dimensions. - Basic, - /// File name, camera, lens and exposure (shutter, aperture, ISO, focal length). - Exposure, -} - -impl InfoOverlay { - pub fn next(self) -> InfoOverlay { - match self { - InfoOverlay::Off => InfoOverlay::Basic, - InfoOverlay::Basic => InfoOverlay::Exposure, - InfoOverlay::Exposure => InfoOverlay::Off, - } - } -} - -/// One line of the loupe's info overlay, chosen in View ▸ View Options (Lightroom Classic's Loupe -/// Info 1 / Loupe Info 2). A line with nothing to show for a photo is left out. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LoupeInfoField { - #[default] - None, - FileName, - /// The capture date and time (the import date when the photo has none). - CaptureDate, - /// Width × height in pixels. - Dimensions, - /// Shutter speed, aperture, ISO and focal length. - Exposure, - Camera, - Lens, - /// Camera and lens on one line. - CameraAndLens, -} - -impl LoupeInfoField { - /// Every field, in the order View Options lists them. - pub const ALL: &'static [LoupeInfoField] = &[ - LoupeInfoField::None, - LoupeInfoField::FileName, - LoupeInfoField::CaptureDate, - LoupeInfoField::Dimensions, - LoupeInfoField::Exposure, - LoupeInfoField::Camera, - LoupeInfoField::Lens, - LoupeInfoField::CameraAndLens, - ]; - - pub fn label(self) -> &'static str { - match self { - LoupeInfoField::None => "None", - LoupeInfoField::FileName => "File Name", - LoupeInfoField::CaptureDate => "Capture Date", - LoupeInfoField::Dimensions => "Dimensions", - LoupeInfoField::Exposure => "Exposure", - LoupeInfoField::Camera => "Camera", - LoupeInfoField::Lens => "Lens", - LoupeInfoField::CameraAndLens => "Camera & Lens", - } - } -} - -/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and -/// Info 2 ([`InfoOverlay::Exposure`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct LoupeInfo { - pub info1: [LoupeInfoField; 3], - pub info2: [LoupeInfoField; 3], -} - -impl LoupeInfo { - /// Info 1's lines before they are changed (and after Use Defaults). - pub const INFO1: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::CaptureDate, LoupeInfoField::Dimensions]; - /// Info 2's lines before they are changed (and after Use Defaults). - pub const INFO2: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::Exposure, LoupeInfoField::CameraAndLens]; -} - -impl Default for LoupeInfo { - fn default() -> Self { - LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2 } - } -} - -/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with -/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the -/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across -/// launches. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct LayoutImage { - /// The graphic's file; empty until one is chosen. - pub path: String, - /// Drawn over the loupe (⌥⌘O). - pub show: bool, - /// The graphic's opacity, 0..=1. - pub opacity: f32, - /// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened). - pub matte: f32, - /// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after - /// it was edited elsewhere). - #[serde(skip)] - pub revision: u32, -} - -impl Default for LayoutImage { - fn default() -> Self { - LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 } - } -} - -impl LayoutImage { - /// The opacity and matte steps View ▸ Loupe Overlay offers. - pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; - pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; -} - -/// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back -/// on): everything but the photo is veiled in black, the panels stay where they are. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LightsOut { - #[default] - On, - /// The interface at 20 % brightness. - Dim, - /// The interface black. - Off, -} - -impl LightsOut { - pub fn next(self) -> LightsOut { - match self { - LightsOut::On => LightsOut::Dim, - LightsOut::Dim => LightsOut::Off, - LightsOut::Off => LightsOut::On, - } - } - - /// Opacity of the black veil over everything but the photo. - pub fn veil(self) -> f32 { - match self { - LightsOut::On => 0.0, - LightsOut::Dim => 0.8, - LightsOut::Off => 1.0, - } - } -} -/// How many libraries File ▸ Open Recent Library remembers. -pub const RECENT_LIBRARIES: usize = 8; - -/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AppearanceMode { - /// Follow the operating system's dark/light setting (dark when it gives no answer). - Auto, - /// The dark theme, whatever the system says (the default: LightCraft's look so far). - #[default] - Dark, - Light, -} - -impl AppearanceMode { - /// The header button's order: Auto, Light, Dark, then Auto again. - pub fn next(self) -> AppearanceMode { - match self { - AppearanceMode::Auto => AppearanceMode::Light, - AppearanceMode::Light => AppearanceMode::Dark, - AppearanceMode::Dark => AppearanceMode::Auto, - } - } -} - -/// The theme the dark appearance uses. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DarkTheme { - #[default] - Charcoal, - Midnight, -} - -/// The theme the light appearance uses. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LightTheme { - #[default] - Silver, - Paper, -} - -impl DarkTheme { - pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; - pub fn kind(self) -> crate::theme::ThemeKind { - match self { - DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, - DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, - } - } -} - -impl LightTheme { - pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; - pub fn kind(self) -> crate::theme::ThemeKind { - match self { - LightTheme::Silver => crate::theme::ThemeKind::Silver, - LightTheme::Paper => crate::theme::ThemeKind::Paper, - } - } -} - -/// App-level preferences (Settings dialog). They belong to the app, not a library, and are -/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library -/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct AppSettings { - /// Library opened at launch when no `--library` is given (empty = the default location). - pub library_path: String, - /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most - /// [`RECENT_LIBRARIES`]). - pub recent_libraries: Vec, - /// When each recent library was last opened (path → the session clock's ISO 8601 time), for - /// the launch window. A separate key so files from earlier versions, and earlier versions - /// reading this one, keep their list (issue #1290). - #[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")] - pub recent_opened: std::collections::BTreeMap, - /// Open the last library at launch, or ask (a value this version doesn't know reads as the - /// default). - #[serde(deserialize_with = "lenient")] - pub at_launch: LaunchLibrary, - pub startup_view: StartupView, - /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved - /// `confirmDelete: false` by default (#1239). - #[serde(rename = "askBeforeDelete")] - pub confirm_delete: bool, - /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask - /// again" turns it off). - pub ask_before_move: bool, - /// A click on the dimmed backdrop around a dialog closes it, like Esc (off: a stray click - /// never throws away a dialog's choices). - pub close_dialogs_outside: bool, - /// Photos and folders dropped on the window import at once, without the import review. - pub import_dropped_directly: bool, - /// GPU rendering allowed (`app.gpu`). - pub gpu: bool, - /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is - /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old - /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). - pub preview_limit: u32, - /// Edit in Other Editor: the application ("" = the system's default for the file type). - pub external_editor: String, - /// The file Edit in Other Editor hands over. - pub external_file: EditFile, - /// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable - /// copy needs its path). - pub photocraft_path: String, - /// The file Edit in PhotoCraft hands over. - pub photocraft_file: EditFile, - /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). - pub memory_mb: u32, - /// Filmstrip: file names above the thumbnails. - pub film_names: bool, - /// Filmstrip: rating / flag / edited badges on the thumbnails. - pub film_badges: bool, - /// Grid: when to show the rating / flag / edited badges. - pub grid_badges: GridBadges, - /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. - pub keymap: crate::shortcuts::Keymap, - /// Auto (follow the system), Dark or Light. - pub appearance_mode: AppearanceMode, - /// The theme of the dark appearance. - pub dark_theme: DarkTheme, - /// The theme of the light appearance. - pub light_theme: LightTheme, - /// Theme shortcuts in the appearance context menu. - #[serde(deserialize_with = "deserialize_favorite_themes")] - pub favorite_themes: Vec, - /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: - /// the display is treated as sRGB). - pub display_profile: String, - /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. - #[serde(deserialize_with = "lenient")] - pub keyword_chips: KeywordChips, - /// View ▸ Focus Point: draw the box the camera focused on (`view.focusPoint`). - pub focus_point: bool, - /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point - /// when the photo has one (`view.zoomToFocus`). - pub zoom_to_focus: bool, - /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. - #[serde(deserialize_with = "lenient")] - pub loupe_info: LoupeInfo, - /// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or - /// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that). - #[serde(deserialize_with = "lenient")] - pub lightroom_import: LightroomImport, -} - -/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as -/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`]. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LightroomImport { - /// A window asks each time: merge into the open library, or create a new library for it. - #[default] - Ask, - /// Merge into the open library without asking (how imports worked before the window). - AlwaysMerge, -} - -impl Default for AppSettings { - fn default() -> Self { - AppSettings { - library_path: String::new(), - recent_libraries: Vec::new(), - recent_opened: Default::default(), - at_launch: LaunchLibrary::Last, - startup_view: StartupView::Last, - confirm_delete: true, - ask_before_move: true, - close_dialogs_outside: false, - import_dropped_directly: false, - gpu: true, - preview_limit: 0, - external_editor: String::new(), - external_file: EditFile::default(), - photocraft_path: String::new(), - photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, - memory_mb: 0, - film_names: true, - film_badges: true, - grid_badges: GridBadges::Auto, - keymap: Default::default(), - appearance_mode: AppearanceMode::Dark, - dark_theme: DarkTheme::Charcoal, - light_theme: LightTheme::Silver, - favorite_themes: Vec::new(), - display_profile: String::new(), - keyword_chips: KeywordChips::default(), - focus_point: false, - zoom_to_focus: true, - loupe_info: LoupeInfo::default(), - lightroom_import: LightroomImport::Ask, - } - } -} - -/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds -/// where it is installed. -pub const PHOTOCRAFT: &str = "PhotoCraft"; - -/// The file format Edit In renders the copy as. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum EditFormat { - #[default] - Tiff, - /// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As). - Psd, -} - -/// The colour space of the Edit In copy. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum EditSpace { - #[default] - AdobeRgb, - ProPhoto, - DisplayP3, - Srgb, -} - -/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct EditFile { - pub format: EditFormat, - pub color_space: EditSpace, - /// 16 or 8. - pub bit_depth: u8, -} - -impl Default for EditFile { - fn default() -> Self { - EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 } - } -} - -impl EditFile { - /// The `photo.editExternal` params for this file. - pub fn params(&self) -> serde_json::Value { - let format = match self.format { - EditFormat::Tiff => "tiff", - EditFormat::Psd => "psd", - }; - let space = match self.color_space { - EditSpace::AdobeRgb => "adobeRgb", - EditSpace::ProPhoto => "proPhoto", - EditSpace::DisplayP3 => "displayP3", - EditSpace::Srgb => "srgb", - }; - serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }}) - } -} - -/// Preview size limits offered in Settings → Performance (0 = Automatic). -pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; - -/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. -pub const LOUPE_EDGE_CEILING: u32 = 8192; - -/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more -/// than the photos there are (a window holds only what is on screen, so the frame costs nothing). -pub const WINDOW_FRAME_CEILING: usize = 65536; - -/// The smallest GPU texture side the loupe plans for, whatever the host reports. -pub const MIN_TEXTURE_SIDE: usize = 512; - -/// Loupe render sizes move in steps of this many pixels. -pub const LOUPE_EDGE_STEP: usize = 64; - -/// Long edge of Build Standard-Sized Previews when the limit is Automatic. -pub const STANDARD_PREVIEW_EDGE: u32 = 2560; - -impl AppSettings { - /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the - /// dark theme otherwise (Auto with no answer from the system included). - pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { - let light = match self.appearance_mode { - AppearanceMode::Light => true, - AppearanceMode::Dark => false, - AppearanceMode::Auto => system == Some(egui::Theme::Light), - }; - if light { self.light_theme.kind() } else { self.dark_theme.kind() } - } - - /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). - pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { - use crate::theme::ThemeKind; - match kind { - ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, - ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, - ThemeKind::Silver => self.light_theme = LightTheme::Silver, - ThemeKind::Paper => self.light_theme = LightTheme::Paper, - } - self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; - } - - /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: - /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at - /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling - /// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build, - /// panics on a bigger one, and many WebGL devices allow only 2048 or 4096). - pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { - let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; - let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE)); - let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize; - let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP; - stepped.min(native_long_edge.max(8)).min(ceiling) - } - - /// Long edge for warming a neighbouring photo: its loupe size but never above the preview - /// source level. A full-size decode would replace the open photo's single full-resolution - /// source in the cache, and nothing is gained by it. - pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { - self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge()) - } - - /// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn - /// over the loupe: capped at the preview source level like the old default. - pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize { - loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE)) - } - - /// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long` - /// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the - /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about - /// the whole-frame render (see [`crate::region::plan`]). - pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { - let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize; - wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING) - } - - /// The edge Build Standard-Sized Previews uses. - pub fn standard_preview_edge(&self) -> u32 { - if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit } - } -} - -/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. -pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; - -/// Grid thumbnail size (points): the bottom-bar slider's range, and the `=` / `-` keys' step -/// (`view.thumbSizeUp` / `view.thumbSizeDown`), about a thirteenth of the range. -pub const THUMB_SIZE_MIN: f32 = 90.0; -pub const THUMB_SIZE_MAX: f32 = 480.0; -pub const THUMB_SIZE_STEP: f32 = 30.0; - -/// Width limits of a side panel the user resizes (points). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct PanelWidth { - pub min: f32, - pub default: f32, - pub max: f32, -} - -impl PanelWidth { - /// `w` within the limits (the default when it isn't a number). - pub fn clamp(&self, w: f32) -> f32 { - if w.is_finite() { w.clamp(self.min, self.max) } else { self.default } - } -} - -/// The left sidebar (sources, albums, folders). -pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 }; -/// The right panel (Edit, Masking, Info, …). -pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 }; -/// The photo area the side panels always leave free (as far as their minimum widths allow). -pub const MIN_PHOTO_WIDTH: f32 = 360.0; - -/// What the Keywording box shows: the keywords (chips to edit), them with the keywords containing -/// them, or what exported files will carry (read only). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum KeywordingView { - #[default] - Keywords, - Containing, - WillExport, -} - -/// How the Keywording box lays out the selection's keywords. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum KeywordChips { - /// One chip per keyword, each with its whole path ("Places › Portugal › Lisbon"). - #[default] - Paths, - /// Keywords inside the same keyword share a line naming it, and their chips say only what is - /// below it; long lists and the suggestions are shortened. - Grouped, -} +mod dialog; +mod settings; +mod view; + +pub use dialog::{Dialog, NameEdit, SyncCounts, default_preset_groups}; +pub use settings::{ + AppSettings, AppearanceMode, DarkTheme, EditFile, EditFormat, EditSpace, GridBadges, KeywordChips, LOUPE_EDGE_CEILING, LOUPE_EDGE_STEP, + LaunchLibrary, LightTheme, LightroomImport, MIN_TEXTURE_SIDE, PHOTOCRAFT, PREVIEW_LIMITS, RECENT_LIBRARIES, STANDARD_PREVIEW_EDGE, StartupView, + WINDOW_FRAME_CEILING, +}; +pub use view::{ + BRIEF_INFO_SECS, BeforeAfter, BriefInfo, CLICK_ZOOMS, CropOverlay, FOCUS_KEYWORDS_PASSES, InfoOverlay, KeywordingView, LEFT_WIDTH, LayoutImage, + LightsOut, LoupeInfo, LoupeInfoField, MIN_PHOTO_WIDTH, PanelWidth, RIGHT_WIDTH, RightPanel, THUMB_SIZE_MAX, THUMB_SIZE_MIN, THUMB_SIZE_STEP, + ToolOverlay, ViewMode, Zoom, +}; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -750,6 +28,9 @@ pub struct UiState { pub window: Option, /// Shared panel arrangement; absent in legacy settings until their first layout. pub docking: Option, + /// The arrangement generation `docking` was saved in ([`crate::docking::DOCKING_VERSION`]); + /// settings files are read through [`crate::docking::drop_outdated`]. + pub docking_version: u32, #[serde(default = "crate::i18n::default_language")] pub language: crate::i18n::Locale, /// The Build Previews run last announced (its identity, finished?). @@ -881,10 +162,12 @@ pub struct UiState { pub external_edits: Vec, #[serde(skip)] pub was_focused: bool, - /// When the watched folder was last scanned (egui time). + /// When the watched folder was last scanned (monotonic app time, so a hidden window keeps + /// scanning). #[serde(skip)] pub auto_import_at: f64, - /// When the automatic XMP writes that failed were last tried again (egui time), and how many were waiting then. + /// When the automatic XMP writes that failed were last tried again (monotonic app time), and how + /// many were waiting then. #[serde(skip)] pub sidecar_retry_at: f64, #[serde(skip)] @@ -900,6 +183,11 @@ pub struct UiState { /// Focus the search field on the next frame (Edit → Find…). #[serde(skip)] pub focus_search: bool, + /// Put the cursor in the Keywords panel's Add keyword box (Add Keywords): the egui pass it was + /// asked on. A box drawn more than [`FOCUS_KEYWORDS_PASSES`] passes later doesn't take it, so + /// a request made while the panel can't show expires. + #[serde(skip)] + pub focus_keywords: Option, /// A mask being renamed in the Masks list: its id and the edited name. #[serde(skip)] pub renaming_mask: Option<(u32, String)>, @@ -911,7 +199,7 @@ pub struct UiState { #[serde(skip)] pub sam_downloading: bool, /// When to start the zoomed-in detail pass of an AI mask (app time) and which mask: set by - /// each click or description, so the pass runs once the clicking stops. + /// each click or description, so the pass runs once the clicking stops (monotonic app time). #[serde(skip)] pub detail_due: Option<(f64, u32)>, /// A mask component being renamed inline: (mask id, component index, name). @@ -1022,6 +310,9 @@ pub struct UiState { pub slideshow: Option>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, + /// When the loupe last changed photo (View Options' Show Briefly When Photo Changes). + #[serde(skip)] + pub brief_info: BriefInfo, /// The loupe's layout image overlay (View ▸ Loupe Overlay). pub layout_image: LayoutImage, /// Navigator mini map in the loupe while zoomed in. @@ -1063,385 +354,12 @@ pub struct UiState { pub print: crate::print_view::PrintUi, } -/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything -/// but crop, masks, spots and red eye. -pub fn default_preset_groups() -> Vec { - lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect() -} - -impl Dialog { - /// A fresh Create Preset dialog. - pub fn create_preset() -> Dialog { - Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() } - } -} - -/// A face's name being typed in the loupe: which face, and what has been typed so far. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct NameEdit { - pub photo: u64, - pub index: usize, - pub text: String, - /// Just opened: the text box takes the keyboard focus once. - pub fresh: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum Dialog { - /// A Lightroom import result stays readable after its activity row disappears. - LightroomResult { - summary: String, - library: Option, - details: Vec, - first: Option, - }, - /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show - /// and the selected cell are [`UiState::print`]. - Print, - ContactSheet { - options: lightcraft_engine::contact_sheet::Options, - }, - /// View ▸ Export Slideshow…: format, size, quality, frame rate, container. - SlideshowExport { - settings: lightcraft_engine::slideshow_export::ExportSettings, - }, - /// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album - /// holds the selected photos (Lightroom's "Include selected photos"; folders hold none). - NewAlbum { - name: String, - folder: bool, - #[serde(default)] - parent: Option, - #[serde(default = "yes")] - include_selected: bool, - }, - RenameAlbum { - id: u64, - name: String, - }, - /// One text field; OK runs `command` with `params` plus `{key: value}` (rename a preset or a - /// version, move a preset to a new group…). - TextPrompt { - title: String, - hint: String, - value: String, - command: String, - params: serde_json::Value, - key: String, - }, - /// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`, - /// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours). - CaptureTime { - mode: String, - time: String, - days: i32, - hours: i32, - minutes: i32, - zone: f32, - }, - /// The import review (File → Import Photos…). - Import { - opts: Box, - }, - /// Edit the colour label names (red, yellow, green, blue, purple; empty = the colour's name). - LabelNames { - names: Vec, - /// Also save the names as a label set of this name (empty = don't). - save_as: String, - }, - /// Batch rename the selected photos with a file-name template. - Rename { - template: String, - start: u32, - }, - /// Rename a keyword on every photo (children included). - RenameKeyword { - from: String, - to: String, - }, - /// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person - /// `to` already names. - RenamePerson { - from: String, - to: String, - merge: bool, - }, - /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. - KeywordTag { - /// The keyword being edited; `None`: a new one. - editing: Option, - name: String, - /// A new keyword goes inside this one when `inside` is on (the keyword picked in the - /// list, or the default parent). - parent: Option, - inside: bool, - /// Comma-separated. - synonyms: String, - include_on_export: bool, - export_containing: bool, - export_synonyms: bool, - person: bool, - /// A new keyword is given to the selected photos. - add_to_selected: bool, - /// Opened from the sidebar's Keywords header: the new keyword is shown there once made. - #[serde(default)] - reveal: bool, - }, - /// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`: - /// the set being edited (renamed when the name changes); `None` saves a new set (from Recent - /// Keywords). - KeywordSet { - replaces: Option, - name: String, - slots: Vec, - /// Save as a new set, leaving the edited one (Lightroom Classic's Save as New Preset). - #[serde(default)] - as_new: bool, - }, - /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: - /// asks before merging the two. - MoveKeyword { - keyword: String, - parent: Option, - }, - /// Delete a keyword from every photo and the keyword list, after asking. - DeleteKeyword { - keyword: String, - /// Photos that have it (or one below it). - count: usize, - }, - /// Merge keywords into another one on every photo. - MergeKeywords { - from: Vec, - into: String, - }, - /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. - AutoStack { - gap: f32, - }, - /// Save the current view (source + filter) as a smart album. - NewSmartAlbum { - name: String, - #[serde(default)] - parent: Option, - }, - /// Help ▸ What's New. - WhatsNew, - /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best - /// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are - /// a burst. - Cull { - reject_below: f32, - pick_best: bool, - burst_gap: f32, - similarity: f32, - }, - /// Help ▸ System Info: (label, value) rows. - DenoiseModel { - info: serde_json::Value, - accepted: bool, - }, - /// A face model file the user chose: what it is, its licence terms, and the "I accept" box. - FaceModel { - path: String, - /// The engine's `faces.models.inspect` answer. - info: serde_json::Value, - accepted: bool, - }, - SystemInfo { - rows: Vec<(String, String)>, - }, - /// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`. - AllMetadata { - title: String, - rows: serde_json::Value, - search: String, - }, - /// Create (`id` None) or edit a smart album's rules. - SmartRules { - id: Option, - name: String, - rules: lightcraft_catalog::RuleSet, - /// The folder a new smart album is created in (ignored when editing). - #[serde(default)] - parent: Option, - }, - /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). - CreatePreset { - name: String, - group: String, - #[serde(default = "default_preset_groups")] - groups: Vec, - }, - CopySettings { - groups: Vec, - }, - /// Paste Selected Settings: which of the copied groups to paste. - PasteSettings { - groups: Vec, - }, - /// `resize` is used unless `full_size`; `limit_kb` 0 = no limit; `dir` empty = default export folder. - Export { - opts: lightcraft_engine::export::ExportOptions, - full_size: bool, - resize: lightcraft_engine::export::Resize, - /// Name typed for "Save as Preset". - #[serde(default)] - preset_name: String, - limit_kb: u32, - dir: String, - }, - /// Photo Merge (HDR / Panorama / HDR Panorama) options; the preview lives in the app. - Merge { - opts: crate::merge::MergeDialog, - }, - /// Settings (preferences): `tab` = general | import | performance | interface. - Settings { - tab: String, - }, - /// Object and Describe masks need the SAM 3 model, which isn't installed: offer to download - /// it (size, licence, progress). `then`: the AI mask to start once it is there (`kind` - /// object|prompt, `op` new|add|subtract|intersect). - SamModel { - then: Option<(String, String)>, - /// Why the download couldn't start (shown in the dialog). - #[serde(default)] - error: Option, - }, - /// Open Library was given a Lightroom Classic catalog (issue #891): offer File ▸ Import - /// Lightroom Catalog… with `catalog` (the `.lrcat` found; `None`: ask for it) instead. - /// `folder`: what was chosen. - LightroomCatalog { - folder: String, - catalog: Option, - }, - /// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see - /// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or - /// (`new_library`) create the library `name` in the folder `location`, switch to it and import - /// there. `dont_ask`: merge from now on without asking. `generation`: the library the window - /// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into. - LightroomDestination { - /// The `.lrcat` file. - catalog: String, - /// The open library's name (its folder's name). - library: String, - /// The photos in the open library. - photos: usize, - /// "Create a new library" is chosen (else "Merge into the open library"). - new_library: bool, - /// The new library's name (prefilled from the catalog's file name). - name: String, - /// The folder the new library goes in (prefilled with the open library's parent folder). - location: String, - /// "Don't ask again (always merge into the open library)". - dont_ask: bool, - /// `Session::library_generation` when the window opened. - generation: u64, - }, - /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, - /// the colour as `color_sat`), whether it carries Amount and the brush settings. - SaveEffect { - name: String, - group: String, - fields: Vec, - amount: bool, - brush: bool, - }, - /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), - /// a delete waiting for confirmation, a new group's name being typed. - ManageEffects { - search: String, - selected: Vec, - rename: Option<(String, String)>, - confirm_delete: bool, - new_group: String, - }, - /// Confirm deleting mask effect presets. - DeleteEffects { - names: Vec, - }, - /// Confirm moving photos to Recently Deleted. - ConfirmDelete { - count: usize, - }, - /// Confirm moving photos dropped on a Folders row into that folder on disk - /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. - MovePhotos { - ids: Vec, - folder: String, - name: String, - dont_ask: bool, - /// The library the photos were dropped in (`Session::library_generation`): ids are - /// numbered per library, so in another one they name other photos. - library: u64, - }, - /// Confirm taking a folder's photos out of the library (`library.removeFolder`). - RemoveFolder { - path: String, - /// What the question calls it (a folder's last two names, a disk's name). - name: String, - count: usize, - /// A whole disk or share (`library.removeFolder` takes it only on request). - disk: bool, - }, - /// Synchronize Folder: what changed in a library folder on disk, and what to do about it - /// (`folder.synchronize`; the scan runs in [`crate::sync`]). - SynchronizeFolder { - path: String, - /// What the dialog calls it (a folder's last two names). - name: String, - /// A whole disk, or a folder holding disks (`folder.synchronize` `disk`). - #[serde(default)] - disk: bool, - /// What the scan found (`None` while it runs). - counts: Option, - import_new: bool, - #[serde(default = "yes")] - relink_moved: bool, - remove_missing: bool, - read_metadata: bool, - }, - About, - Shortcuts, - /// View ▸ View Options (⌘J): the info overlay and the lines of Info 1 and Info 2. Changes apply - /// as they are made. - ViewOptions, -} - -fn yes() -> bool { - true -} - -/// How many changes a Synchronize Folder scan found, by kind. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SyncCounts { - /// The folder isn't there (moved, renamed, or on a disk that isn't connected). - #[serde(default)] - pub offline: bool, - pub new: usize, - pub duplicates: usize, - pub unreadable: usize, - pub missing: usize, - pub metadata: usize, - #[serde(default)] - pub moved: usize, -} - -impl SyncCounts { - /// The folder is there and the scan found nothing to bring in, relink, remove or read: the - /// dialog only says so (files it skipped are not changes). - pub fn up_to_date(&self) -> bool { - !self.offline && self.new + self.moved + self.missing + self.metadata == 0 - } -} - impl Default for UiState { fn default() -> Self { UiState { window: None, docking: None, + docking_version: crate::docking::DOCKING_VERSION, language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, @@ -1508,6 +426,7 @@ impl Default for UiState { known_cards: None, search: String::new(), focus_search: false, + focus_keywords: None, renaming_mask: None, describe: None, detail_due: None, @@ -1555,6 +474,7 @@ impl Default for UiState { keyword_list_selected: None, dragging_keyword: None, info_overlay: InfoOverlay::Off, + brief_info: BriefInfo::default(), layout_image: LayoutImage::default(), navigator: true, settings: AppSettings::default(), @@ -1667,51 +587,49 @@ impl UiState { } } -/// A setting read leniently: a value that isn't one of its own (a hand-edited file, a newer -/// build's choice) is the default, and the rest of the settings file is still read. -fn lenient<'de, D: serde::Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) -} - /// A list of ids read leniently: what isn't a list is none, and what isn't text in it is left out. fn lenient_ids<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { let value = serde_json::Value::deserialize(d)?; Ok(value.as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect()) } -fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let values = Vec::::deserialize(d)?; - let mut seen = std::collections::BTreeSet::new(); - for id in &values { - if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) { - return Err(serde::de::Error::custom("unknown or duplicate favorite theme")); - } - } - Ok(values) +/// The navigation/document/modal/panel state a gesture was admitted under. The same automation +/// id can mean a different control after any of these changes; the request is canceled rather +/// than retargeted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GestureFrame { + pub(crate) view: crate::state::ViewMode, + pub(crate) active: Option, + pub(crate) library: u64, + pub(crate) dialog: Option>, + pub(crate) left_panel: bool, + pub(crate) right: crate::state::RightPanel, + pub(crate) presets: bool, + pub(crate) filmstrip: bool, } -#[cfg(test)] -mod loupe_info_tests { - use super::*; +/// A pointer press delivered to the UI, kept so a canceled gesture knows that a button is down. +#[derive(Clone, Copy, Debug)] +pub(crate) struct InjectedPress { + pub(crate) button: egui::PointerButton, +} - /// The lines chosen in View Options are saved with the app's settings; a settings file from - /// before they existed, or with a line this build doesn't know, gets the default lines and - /// keeps the rest of its settings. - #[test] - fn view_options_lines_are_saved_with_the_settings() { - let mut settings = AppSettings::default(); - settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; - let saved = serde_json::to_value(&settings).unwrap(); - assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); - let read: AppSettings = serde_json::from_value(saved).unwrap(); - assert_eq!(read.loupe_info, settings.loupe_info); +/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`. +#[derive(Debug)] +pub(crate) struct InjectedDrop(pub(crate) std::path::PathBuf); - let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); - assert_eq!(old.loupe_info, LoupeInfo::default()); - let newer: AppSettings = - serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap(); - assert_eq!(newer.loupe_info, LoupeInfo::default()); - assert!(!newer.film_names, "the rest of the settings are still read"); +impl egui::DroppedFile for InjectedDrop { + fn path(&self) -> &std::path::Path { + &self.0 + } + + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { + Box::pin(async { Err("the web build has no files to read".to_string()) }) } } diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs new file mode 100644 index 000000000..edc4946cd --- /dev/null +++ b/crates/ui-egui/src/state/dialog.rs @@ -0,0 +1,375 @@ +use super::*; + +/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything +/// but crop, masks, spots and red eye. +pub fn default_preset_groups() -> Vec { + lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect() +} + +impl Dialog { + /// A fresh Create Preset dialog. + pub fn create_preset() -> Dialog { + Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() } + } +} + +/// A face's name being typed in the loupe: which face, and what has been typed so far. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NameEdit { + pub photo: u64, + pub index: usize, + pub text: String, + /// Just opened: the text box takes the keyboard focus once. + pub fresh: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Dialog { + /// A Lightroom import result stays readable after its activity row disappears. + LightroomResult { + summary: String, + library: Option, + details: Vec, + first: Option, + }, + /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show + /// and the selected cell are [`UiState::print`]. + Print, + ContactSheet { + options: lightcraft_engine::contact_sheet::Options, + }, + /// View ▸ Export Slideshow…: format, size, quality, frame rate, container. + SlideshowExport { + settings: lightcraft_engine::slideshow_export::ExportSettings, + }, + /// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album + /// holds the selected photos (Lightroom's "Include selected photos"; folders hold none). + NewAlbum { + name: String, + folder: bool, + #[serde(default)] + parent: Option, + #[serde(default = "yes")] + include_selected: bool, + }, + RenameAlbum { + id: u64, + name: String, + }, + /// One text field; OK runs `command` with `params` plus `{key: value}` (rename a preset or a + /// version, move a preset to a new group…). + TextPrompt { + title: String, + hint: String, + value: String, + command: String, + params: serde_json::Value, + key: String, + }, + /// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`, + /// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours). + CaptureTime { + mode: String, + time: String, + days: i32, + hours: i32, + minutes: i32, + zone: f32, + }, + /// The import review (File → Import Photos…). + Import { + opts: Box, + }, + /// Edit the colour label names (red, yellow, green, blue, purple; empty = the colour's name). + LabelNames { + names: Vec, + /// Also save the names as a label set of this name (empty = don't). + save_as: String, + }, + /// Batch rename the selected photos with a file-name template. + Rename { + template: String, + start: u32, + }, + /// Rename a keyword on every photo (children included). + RenameKeyword { + from: String, + to: String, + }, + /// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person + /// `to` already names. + RenamePerson { + from: String, + to: String, + merge: bool, + }, + /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. + KeywordTag { + /// The keyword being edited; `None`: a new one. + editing: Option, + name: String, + /// A new keyword goes inside this one when `inside` is on (the keyword picked in the + /// list, or the default parent). + parent: Option, + inside: bool, + /// Comma-separated. + synonyms: String, + include_on_export: bool, + export_containing: bool, + export_synonyms: bool, + person: bool, + /// A new keyword is given to the selected photos. + add_to_selected: bool, + /// Opened from the sidebar's Keywords header: the new keyword is shown there once made. + #[serde(default)] + reveal: bool, + }, + /// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`: + /// the set being edited (renamed when the name changes); `None` saves a new set (from Recent + /// Keywords). + KeywordSet { + replaces: Option, + name: String, + slots: Vec, + /// Save as a new set, leaving the edited one (Lightroom Classic's Save as New Preset). + #[serde(default)] + as_new: bool, + }, + /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: + /// asks before merging the two. + MoveKeyword { + keyword: String, + parent: Option, + }, + /// Delete a keyword from every photo and the keyword list, after asking. + DeleteKeyword { + keyword: String, + /// Photos that have it (or one below it). + count: usize, + }, + /// Merge keywords into another one on every photo. + MergeKeywords { + from: Vec, + into: String, + }, + /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. + AutoStack { + gap: f32, + }, + /// Save the current view (source + filter) as a smart album. + NewSmartAlbum { + name: String, + #[serde(default)] + parent: Option, + }, + /// Help ▸ What's New. + WhatsNew, + /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best + /// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are + /// a burst. + Cull { + reject_below: f32, + pick_best: bool, + burst_gap: f32, + similarity: f32, + }, + /// Help ▸ System Info: (label, value) rows. + DenoiseModel { + info: serde_json::Value, + accepted: bool, + }, + /// A face model file the user chose: what it is, its licence terms, and the "I accept" box. + FaceModel { + path: String, + /// The engine's `faces.models.inspect` answer. + info: serde_json::Value, + accepted: bool, + }, + SystemInfo { + rows: Vec<(String, String)>, + }, + /// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`. + AllMetadata { + title: String, + rows: serde_json::Value, + search: String, + }, + /// Create (`id` None) or edit a smart album's rules. + SmartRules { + id: Option, + name: String, + rules: lightcraft_catalog::RuleSet, + /// The folder a new smart album is created in (ignored when editing). + #[serde(default)] + parent: Option, + }, + /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). + CreatePreset { + name: String, + group: String, + #[serde(default = "default_preset_groups")] + groups: Vec, + }, + CopySettings { + groups: Vec, + }, + /// Paste Selected Settings: which of the copied groups to paste. + PasteSettings { + groups: Vec, + }, + /// `resize` is used unless `full_size`; `limit_kb` 0 = no limit; `dir` empty = default export folder. + Export { + opts: lightcraft_engine::export::ExportOptions, + full_size: bool, + resize: lightcraft_engine::export::Resize, + /// Name typed for "Save as Preset". + #[serde(default)] + preset_name: String, + limit_kb: u32, + dir: String, + }, + /// Photo Merge (HDR / Panorama / HDR Panorama) options; the preview lives in the app. + Merge { + opts: crate::merge::MergeDialog, + }, + /// Settings (preferences): `tab` = general | import | performance | interface. + Settings { + tab: String, + }, + /// Object and Describe masks need the SAM 3 model, which isn't installed: offer to download + /// it (size, licence, progress). `then`: the AI mask to start once it is there (`kind` + /// object|prompt, `op` new|add|subtract|intersect). + SamModel { + then: Option<(String, String)>, + /// Why the download couldn't start (shown in the dialog). + #[serde(default)] + error: Option, + }, + /// Open Library was given a Lightroom Classic catalog (issue #891): offer File ▸ Import + /// Lightroom Catalog… with `catalog` (the `.lrcat` found; `None`: ask for it) instead. + /// `folder`: what was chosen. + LightroomCatalog { + folder: String, + catalog: Option, + }, + /// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see + /// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or + /// (`new_library`) create the library `name` in the folder `location`, switch to it and import + /// there. `dont_ask`: merge from now on without asking. `generation`: the library the window + /// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into. + LightroomDestination { + /// The `.lrcat` file. + catalog: String, + /// The open library's name (its folder's name). + library: String, + /// The photos in the open library. + photos: usize, + /// "Create a new library" is chosen (else "Merge into the open library"). + new_library: bool, + /// The new library's name (prefilled from the catalog's file name). + name: String, + /// The folder the new library goes in (prefilled with the open library's parent folder). + location: String, + /// "Don't ask again (always merge into the open library)". + dont_ask: bool, + /// `Session::library_generation` when the window opened. + generation: u64, + }, + /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, + /// the colour as `color_sat`), whether it carries Amount and the brush settings. + SaveEffect { + name: String, + group: String, + fields: Vec, + amount: bool, + brush: bool, + }, + /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), + /// a delete waiting for confirmation, a new group's name being typed. + ManageEffects { + search: String, + selected: Vec, + rename: Option<(String, String)>, + confirm_delete: bool, + new_group: String, + }, + /// Confirm deleting mask effect presets. + DeleteEffects { + names: Vec, + }, + /// Confirm moving photos to Recently Deleted. + ConfirmDelete { + count: usize, + }, + /// Confirm moving photos dropped on a Folders row into that folder on disk + /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. + MovePhotos { + ids: Vec, + folder: String, + name: String, + dont_ask: bool, + /// The library the photos were dropped in (`Session::library_generation`): ids are + /// numbered per library, so in another one they name other photos. + library: u64, + }, + /// Confirm taking a folder's photos out of the library (`library.removeFolder`). + RemoveFolder { + path: String, + /// What the question calls it (a folder's last two names, a disk's name). + name: String, + count: usize, + /// A whole disk or share (`library.removeFolder` takes it only on request). + disk: bool, + }, + /// Synchronize Folder: what changed in a library folder on disk, and what to do about it + /// (`folder.synchronize`; the scan runs in [`crate::sync`]). + SynchronizeFolder { + path: String, + /// What the dialog calls it (a folder's last two names). + name: String, + /// A whole disk, or a folder holding disks (`folder.synchronize` `disk`). + #[serde(default)] + disk: bool, + /// What the scan found (`None` while it runs). + counts: Option, + import_new: bool, + #[serde(default = "yes")] + relink_moved: bool, + remove_missing: bool, + read_metadata: bool, + }, + About, + Shortcuts, + /// View ▸ View Options (⌘J): the info overlay and the lines of Info 1 and Info 2. Changes apply + /// as they are made. + ViewOptions, +} + +fn yes() -> bool { + true +} + +/// How many changes a Synchronize Folder scan found, by kind. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncCounts { + /// The folder isn't there (moved, renamed, or on a disk that isn't connected). + #[serde(default)] + pub offline: bool, + pub new: usize, + pub duplicates: usize, + pub unreadable: usize, + pub missing: usize, + pub metadata: usize, + #[serde(default)] + pub moved: usize, +} + +impl SyncCounts { + /// The folder is there and the scan found nothing to bring in, relink, remove or read: the + /// dialog only says so (files it skipped are not changes). + pub fn up_to_date(&self) -> bool { + !self.offline && self.new + self.moved + self.missing + self.metadata == 0 + } +} diff --git a/crates/ui-egui/src/state/settings.rs b/crates/ui-egui/src/state/settings.rs new file mode 100644 index 000000000..2d748f5a7 --- /dev/null +++ b/crates/ui-egui/src/state/settings.rs @@ -0,0 +1,464 @@ +use super::view::LoupeInfo; +#[cfg(test)] +use super::view::{InfoOverlay, LoupeInfoField}; +use super::*; + +/// Which view the app opens in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartupView { + /// Whatever was showing at quit. + #[default] + Last, + Grid, + Detail, +} + +/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library` +/// and `LIGHTCRAFT_LIBRARY` come first either way. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LaunchLibrary { + /// The library open when the app last quit (holding Alt/Option as the window appears asks once). + #[default] + Last, + /// A window lists the recent libraries to choose from. + Ask, +} + +/// When grid cells show their rating / flag / edited badges. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridBadges { + /// On hover, on the selection, and on rated or flagged photos. + #[default] + Auto, + Always, + Never, +} + +/// How many libraries File ▸ Open Recent Library remembers. +pub const RECENT_LIBRARIES: usize = 8; + +/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppearanceMode { + /// Follow the operating system's dark/light setting (dark when it gives no answer). + Auto, + /// The dark theme, whatever the system says (the default: LightCraft's look so far). + #[default] + Dark, + Light, +} + +impl AppearanceMode { + /// The header button's order: Auto, Light, Dark, then Auto again. + pub fn next(self) -> AppearanceMode { + match self { + AppearanceMode::Auto => AppearanceMode::Light, + AppearanceMode::Light => AppearanceMode::Dark, + AppearanceMode::Dark => AppearanceMode::Auto, + } + } +} + +/// The theme the dark appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DarkTheme { + #[default] + Charcoal, + Midnight, +} + +/// The theme the light appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTheme { + #[default] + Silver, + Paper, +} + +impl DarkTheme { + pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, + DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, + } + } +} + +impl LightTheme { + pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + LightTheme::Silver => crate::theme::ThemeKind::Silver, + LightTheme::Paper => crate::theme::ThemeKind::Paper, + } + } +} + +/// App-level preferences (Settings dialog). They belong to the app, not a library, and are +/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library +/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AppSettings { + /// Library opened at launch when no `--library` is given (empty = the default location). + pub library_path: String, + /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most + /// [`RECENT_LIBRARIES`]). + pub recent_libraries: Vec, + /// When each recent library was last opened (path → the session clock's ISO 8601 time), for + /// the launch window. A separate key so files from earlier versions, and earlier versions + /// reading this one, keep their list (issue #1290). + #[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")] + pub recent_opened: std::collections::BTreeMap, + /// Open the last library at launch, or ask (a value this version doesn't know reads as the + /// default). + #[serde(deserialize_with = "lenient")] + pub at_launch: LaunchLibrary, + pub startup_view: StartupView, + /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved + /// `confirmDelete: false` by default (#1239). + #[serde(rename = "askBeforeDelete")] + pub confirm_delete: bool, + /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask + /// again" turns it off). + pub ask_before_move: bool, + /// A click on the dimmed backdrop around a dialog closes it, like Esc (off: a stray click + /// never throws away a dialog's choices). + pub close_dialogs_outside: bool, + /// Photos and folders dropped on the window import at once, without the import review. + pub import_dropped_directly: bool, + /// GPU rendering allowed (`app.gpu`). + pub gpu: bool, + /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is + /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old + /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). + pub preview_limit: u32, + /// Edit in Other Editor: the application ("" = the system's default for the file type). + pub external_editor: String, + /// The file Edit in Other Editor hands over. + pub external_file: EditFile, + /// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable + /// copy needs its path). + pub photocraft_path: String, + /// The file Edit in PhotoCraft hands over. + pub photocraft_file: EditFile, + /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). + pub memory_mb: u32, + /// The Info panel's metadata fields with several photos selected: true (the default) shows the + /// values the selected photos share and marks each field where they differ (Selected Photos), + /// false shows the active photo's values (Target Photo) (`view.infoSelectedPhotos`). + pub info_selected_photos: bool, + /// Filmstrip: file names above the thumbnails. + pub film_names: bool, + /// Filmstrip: rating / flag / edited badges on the thumbnails. + pub film_badges: bool, + /// Grid: when to show the rating / flag / edited badges. + pub grid_badges: GridBadges, + /// View ▸ View Options ▸ Show message when loading or rendering photos: the loupe says + /// "Rendering…" while the photo it shows is not the finished render. + pub loupe_messages: bool, + /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. + pub keymap: crate::shortcuts::Keymap, + /// Auto (follow the system), Dark or Light. + pub appearance_mode: AppearanceMode, + /// The theme of the dark appearance. + pub dark_theme: DarkTheme, + /// The theme of the light appearance. + pub light_theme: LightTheme, + /// Theme shortcuts in the appearance context menu. + #[serde(deserialize_with = "deserialize_favorite_themes")] + pub favorite_themes: Vec, + /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: + /// the display is treated as sRGB). + pub display_profile: String, + /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. + #[serde(deserialize_with = "lenient")] + pub keyword_chips: KeywordChips, + /// View ▸ Focus Point: draw the box the camera focused on (`view.focusPoint`). + pub focus_point: bool, + /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point + /// when the photo has one (`view.zoomToFocus`). + pub zoom_to_focus: bool, + /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. + #[serde(deserialize_with = "lenient")] + pub loupe_info: LoupeInfo, + /// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or + /// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that). + #[serde(deserialize_with = "lenient")] + pub lightroom_import: LightroomImport, +} + +/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as +/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightroomImport { + /// A window asks each time: merge into the open library, or create a new library for it. + #[default] + Ask, + /// Merge into the open library without asking (how imports worked before the window). + AlwaysMerge, +} + +impl Default for AppSettings { + fn default() -> Self { + AppSettings { + library_path: String::new(), + recent_libraries: Vec::new(), + recent_opened: Default::default(), + at_launch: LaunchLibrary::Last, + startup_view: StartupView::Last, + confirm_delete: true, + ask_before_move: true, + close_dialogs_outside: false, + import_dropped_directly: false, + gpu: true, + preview_limit: 0, + external_editor: String::new(), + external_file: EditFile::default(), + photocraft_path: String::new(), + photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, + memory_mb: 0, + info_selected_photos: true, + film_names: true, + film_badges: true, + grid_badges: GridBadges::Auto, + loupe_messages: true, + keymap: Default::default(), + appearance_mode: AppearanceMode::Dark, + dark_theme: DarkTheme::Charcoal, + light_theme: LightTheme::Silver, + favorite_themes: Vec::new(), + display_profile: String::new(), + keyword_chips: KeywordChips::default(), + focus_point: false, + zoom_to_focus: true, + loupe_info: LoupeInfo::default(), + lightroom_import: LightroomImport::Ask, + } + } +} + +/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds +/// where it is installed. +pub const PHOTOCRAFT: &str = "PhotoCraft"; + +/// The file format Edit In renders the copy as. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditFormat { + #[default] + Tiff, + /// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As). + Psd, +} + +/// The colour space of the Edit In copy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditSpace { + #[default] + AdobeRgb, + ProPhoto, + DisplayP3, + Srgb, +} + +/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EditFile { + pub format: EditFormat, + pub color_space: EditSpace, + /// 16 or 8. + pub bit_depth: u8, +} + +impl Default for EditFile { + fn default() -> Self { + EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 } + } +} + +impl EditFile { + /// The `photo.editExternal` params for this file. + pub fn params(&self) -> serde_json::Value { + let format = match self.format { + EditFormat::Tiff => "tiff", + EditFormat::Psd => "psd", + }; + let space = match self.color_space { + EditSpace::AdobeRgb => "adobeRgb", + EditSpace::ProPhoto => "proPhoto", + EditSpace::DisplayP3 => "displayP3", + EditSpace::Srgb => "srgb", + }; + serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }}) + } +} + +/// Preview size limits offered in Settings → Performance (0 = Automatic). +pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; + +/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. +pub const LOUPE_EDGE_CEILING: u32 = 8192; + +/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more +/// than the photos there are (a window holds only what is on screen, so the frame costs nothing). +pub const WINDOW_FRAME_CEILING: usize = 65536; + +/// The smallest GPU texture side the loupe plans for, whatever the host reports. +pub const MIN_TEXTURE_SIDE: usize = 512; + +/// Loupe render sizes move in steps of this many pixels. +pub const LOUPE_EDGE_STEP: usize = 64; + +/// Long edge of Build Standard-Sized Previews when the limit is Automatic. +pub const STANDARD_PREVIEW_EDGE: u32 = 2560; + +impl AppSettings { + /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the + /// dark theme otherwise (Auto with no answer from the system included). + pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { + let light = match self.appearance_mode { + AppearanceMode::Light => true, + AppearanceMode::Dark => false, + AppearanceMode::Auto => system == Some(egui::Theme::Light), + }; + if light { self.light_theme.kind() } else { self.dark_theme.kind() } + } + + /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). + pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { + use crate::theme::ThemeKind; + match kind { + ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, + ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, + ThemeKind::Silver => self.light_theme = LightTheme::Silver, + ThemeKind::Paper => self.light_theme = LightTheme::Paper, + } + self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; + } + + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: + /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at + /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling + /// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build, + /// panics on a bigger one, and many WebGL devices allow only 2048 or 4096). + pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { + let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; + let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE)); + let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize; + let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP; + stepped.min(native_long_edge.max(8)).min(ceiling) + } + + /// Long edge for warming a neighbouring photo: its loupe size but never above the preview + /// source level. A full-size decode would replace the open photo's single full-resolution + /// source in the cache, and nothing is gained by it. + pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { + self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge()) + } + + /// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn + /// over the loupe: capped at the preview source level like the old default. + pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize { + loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE)) + } + + /// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long` + /// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the + /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about + /// the whole-frame render (see [`crate::region::plan`]). + pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { + let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize; + wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING) + } + + /// The edge Build Standard-Sized Previews uses. + pub fn standard_preview_edge(&self) -> u32 { + if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit } + } +} + +/// How the Keywording box lays out the selection's keywords. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeywordChips { + /// One chip per keyword, each with its whole path ("Places › Portugal › Lisbon"). + #[default] + Paths, + /// Keywords inside the same keyword share a line naming it, and their chips say only what is + /// below it; long lists and the suggestions are shortened. + Grouped, +} + +/// A setting read leniently: a value that isn't one of its own (a hand-edited file, a newer +/// build's choice) is the default, and the rest of the settings file is still read. +fn lenient<'de, D: serde::Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let values = Vec::::deserialize(d)?; + let mut seen = std::collections::BTreeSet::new(); + for id in &values { + if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) { + return Err(serde::de::Error::custom("unknown or duplicate favorite theme")); + } + } + Ok(values) +} + +#[cfg(test)] +mod loupe_info_tests { + use super::*; + + /// The lines chosen in View Options are saved with the app's settings; a settings file from + /// before they existed, or with a line this build doesn't know, gets the default lines and + /// keeps the rest of its settings. + #[test] + fn view_options_lines_are_saved_with_the_settings() { + let mut settings = AppSettings::default(); + settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; + settings.loupe_info.briefly = InfoOverlay::Exposure; + let saved = serde_json::to_value(&settings).unwrap(); + assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); + assert_eq!(saved["loupeInfo"]["briefly"], "exposure"); + let read: AppSettings = serde_json::from_value(saved).unwrap(); + assert_eq!(read.loupe_info, settings.loupe_info); + + let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); + assert_eq!(old.loupe_info, LoupeInfo::default()); + // lines saved before Show Briefly When Photo Changes existed are kept, with it off + let lines: AppSettings = serde_json::from_value(serde_json::json!({"loupeInfo": {"info2": ["lens", "none", "fileName"]}})).unwrap(); + assert_eq!(lines.loupe_info.info2, settings.loupe_info.info2); + assert_eq!(lines.loupe_info.briefly, InfoOverlay::Off); + let newer: AppSettings = + serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap(); + assert_eq!(newer.loupe_info, LoupeInfo::default()); + assert!(!newer.film_names, "the rest of the settings are still read"); + } + + /// Show message when loading or rendering photos is on until it is turned off, and is saved + /// with the app's settings. + #[test] + fn the_loading_message_choice_is_saved_with_the_settings() { + assert!(AppSettings::default().loupe_messages); + let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); + assert!(old.loupe_messages, "a settings file from before the choice existed keeps the message"); + let settings = AppSettings { loupe_messages: false, ..AppSettings::default() }; + let saved = serde_json::to_value(&settings).unwrap(); + assert_eq!(saved["loupeMessages"], false); + let read: AppSettings = serde_json::from_value(saved).unwrap(); + assert!(!read.loupe_messages); + } +} diff --git a/crates/ui-egui/src/state/view.rs b/crates/ui-egui/src/state/view.rs new file mode 100644 index 000000000..1bbfd4349 --- /dev/null +++ b/crates/ui-egui/src/state/view.rs @@ -0,0 +1,379 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ViewMode { + /// Justified rows ("Photo Grid"). + #[default] + PhotoGrid, + SquareGrid, + Detail, + /// Two photos side by side (select | candidate), synced zoom. + Compare, + /// The selected photos tiled. + Survey, + /// A reference photo (left, fixed) beside the active photo (right, being edited). + Reference, + /// A card per person named on faces (close-up, name, photo count). + People, +} + +/// The right-hand tool/panel shown next to the tool strip. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RightPanel { + #[default] + None, + Edit, + Crop, + Remove, + Masking, + RedEye, + Versions, + Activity, + Keywords, + Info, + /// The profile browser (part of Edit). + Profiles, +} + +impl RightPanel { + pub fn is_edit_tool(self) -> bool { + matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Zoom { + #[default] + Fit, + Fill, + /// 100 % = one image pixel per physical screen pixel. + Percent(f32), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BeforeAfter { + #[default] + Off, + /// Hold `\`: show the original. + Original, + SideBySide, + Split, + /// Before above after. + TopBottom, + /// One image split horizontally: before above the line. + SplitTopBottom, +} + +impl BeforeAfter { + /// One of the Before / After layouts (two views or a split line), which the bottom bar's + /// Before/After (Y) button stands for. Show Original (`\`) is a view of its own, not a layout. + pub fn is_layout(self) -> bool { + !matches!(self, BeforeAfter::Off | BeforeAfter::Original) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CropOverlay { + #[default] + Thirds, + Grid, + Golden, + Diagonal, + Triangle, + Spiral, + None, +} + +/// Tool Overlay: when the retouching tools' pins (Remove / Heal / Clone spots, mask pins) show on +/// the photo. Shift+H steps through the modes in this order (`view.toolOverlay`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ToolOverlay { + /// While the pointer is over the photo (hidden while a new spot or brush stroke is painted). + #[default] + Auto, + Always, + /// Only the selected spot's or mask's. + Selected, + /// None; the tool still works (a click still adds a spot). + Never, +} + +impl ToolOverlay { + pub const ALL: [ToolOverlay; 4] = [ToolOverlay::Auto, ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never]; + + /// The id used by `view.toolOverlay {mode}` and the saved settings. + pub fn name(self) -> &'static str { + match self { + ToolOverlay::Auto => "auto", + ToolOverlay::Always => "always", + ToolOverlay::Selected => "selected", + ToolOverlay::Never => "never", + } + } + + pub fn parse(s: &str) -> Option { + ToolOverlay::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s.trim())) + } + + /// Menu label. + pub fn label(self) -> &'static str { + match self { + ToolOverlay::Auto => "Auto", + ToolOverlay::Always => "Always", + ToolOverlay::Selected => "Selected", + ToolOverlay::Never => "Never", + } + } + + /// The mode Shift+H steps to. + pub fn next(self) -> ToolOverlay { + match self { + ToolOverlay::Auto => ToolOverlay::Always, + ToolOverlay::Always => ToolOverlay::Selected, + ToolOverlay::Selected => ToolOverlay::Never, + ToolOverlay::Never => ToolOverlay::Auto, + } + } + + /// Whether a pin shows: `selected` = its spot or mask is the selected one, `live` = the pointer + /// is over the photo (or a pin is being dragged), which is what Auto waits for. + pub fn shows(self, selected: bool, live: bool) -> bool { + match self { + ToolOverlay::Auto => live, + ToolOverlay::Always => true, + ToolOverlay::Selected => selected, + ToolOverlay::Never => false, + } + } +} + +/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InfoOverlay { + #[default] + Off, + /// File name, capture date and dimensions. + Basic, + /// File name, camera, lens and exposure (shutter, aperture, ISO, focal length). + Exposure, +} + +impl InfoOverlay { + pub fn next(self) -> InfoOverlay { + match self { + InfoOverlay::Off => InfoOverlay::Basic, + InfoOverlay::Basic => InfoOverlay::Exposure, + InfoOverlay::Exposure => InfoOverlay::Off, + } + } +} + +/// One line of the loupe's info overlay, chosen in View ▸ View Options (Lightroom Classic's Loupe +/// Info 1 / Loupe Info 2). A line with nothing to show for a photo is left out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LoupeInfoField { + #[default] + None, + FileName, + /// The capture date and time (the import date when the photo has none). + CaptureDate, + /// Width × height in pixels. + Dimensions, + /// Shutter speed, aperture, ISO and focal length. + Exposure, + Camera, + Lens, + /// Camera and lens on one line. + CameraAndLens, +} + +impl LoupeInfoField { + /// Every field, in the order View Options lists them. + pub const ALL: &'static [LoupeInfoField] = &[ + LoupeInfoField::None, + LoupeInfoField::FileName, + LoupeInfoField::CaptureDate, + LoupeInfoField::Dimensions, + LoupeInfoField::Exposure, + LoupeInfoField::Camera, + LoupeInfoField::Lens, + LoupeInfoField::CameraAndLens, + ]; + + pub fn label(self) -> &'static str { + match self { + LoupeInfoField::None => "None", + LoupeInfoField::FileName => "File Name", + LoupeInfoField::CaptureDate => "Capture Date", + LoupeInfoField::Dimensions => "Dimensions", + LoupeInfoField::Exposure => "Exposure", + LoupeInfoField::Camera => "Camera", + LoupeInfoField::Lens => "Lens", + LoupeInfoField::CameraAndLens => "Camera & Lens", + } + } +} + +/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and +/// Info 2 ([`InfoOverlay::Exposure`]), and which of them Show Briefly When Photo Changes shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoupeInfo { + pub info1: [LoupeInfoField; 3], + pub info2: [LoupeInfoField; 3], + /// Show Briefly When Photo Changes: while the info overlay is off, this Info shows for + /// [`BRIEF_INFO_SECS`] each time the loupe changes to another photo ([`InfoOverlay::Off`]: + /// neither). + pub briefly: InfoOverlay, +} + +/// Passes after Add Keywords within which the Add keyword box takes the cursor (the panel may +/// need a pass to open first). +pub const FOCUS_KEYWORDS_PASSES: u64 = 2; + +/// How long Show Briefly When Photo Changes shows the info overlay (seconds). +pub const BRIEF_INFO_SECS: f64 = 3.0; + +/// When the loupe last changed photo, for Show Briefly When Photo Changes +/// ([`LoupeInfo::briefly`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BriefInfo { + /// The photo the loupe showed last. + pub photo: Option, + /// When the loupe changed to it (seconds on egui's clock). + pub since: f64, + /// The brief info overlay shows this frame. + pub shown: bool, +} + +impl LoupeInfo { + /// Info 1's lines before they are changed (and after Use Defaults). + pub const INFO1: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::CaptureDate, LoupeInfoField::Dimensions]; + /// Info 2's lines before they are changed (and after Use Defaults). + pub const INFO2: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::Exposure, LoupeInfoField::CameraAndLens]; +} + +impl Default for LoupeInfo { + fn default() -> Self { + LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2, briefly: InfoOverlay::Off } + } +} + +/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with +/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the +/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across +/// launches. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayoutImage { + /// The graphic's file; empty until one is chosen. + pub path: String, + /// Drawn over the loupe (⌥⌘O). + pub show: bool, + /// The graphic's opacity, 0..=1. + pub opacity: f32, + /// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened). + pub matte: f32, + /// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after + /// it was edited elsewhere). + #[serde(skip)] + pub revision: u32, +} + +impl Default for LayoutImage { + fn default() -> Self { + LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 } + } +} + +impl LayoutImage { + /// The opacity and matte steps View ▸ Loupe Overlay offers. + pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; + pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; +} + +/// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back +/// on): everything but the photo is veiled in black, the panels stay where they are. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightsOut { + #[default] + On, + /// The interface at 20 % brightness. + Dim, + /// The interface black. + Off, +} + +impl LightsOut { + pub fn next(self) -> LightsOut { + match self { + LightsOut::On => LightsOut::Dim, + LightsOut::Dim => LightsOut::Off, + LightsOut::Off => LightsOut::On, + } + } + + /// Opacity of the black veil over everything but the photo. + pub fn veil(self) -> f32 { + match self { + LightsOut::On => 0.0, + LightsOut::Dim => 0.8, + LightsOut::Off => 1.0, + } + } +} + +/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. +pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; + +/// Grid thumbnail size (points): the bottom-bar slider's range, and the `=` / `-` keys' step +/// (`view.thumbSizeUp` / `view.thumbSizeDown`), about a thirteenth of the range. +pub const THUMB_SIZE_MIN: f32 = 90.0; + +pub const THUMB_SIZE_MAX: f32 = 480.0; + +pub const THUMB_SIZE_STEP: f32 = 30.0; + +/// Width limits of a side panel the user resizes (points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PanelWidth { + pub min: f32, + pub default: f32, + pub max: f32, +} + +impl PanelWidth { + /// `w` within the limits (the default when it isn't a number). + pub fn clamp(&self, w: f32) -> f32 { + if w.is_finite() { w.clamp(self.min, self.max) } else { self.default } + } +} + +/// The left sidebar (sources, albums, folders). +pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 }; + +/// The right panel (Edit, Masking, Info, …). +pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 }; + +/// The photo area the side panels always leave free (as far as their minimum widths allow). +pub const MIN_PHOTO_WIDTH: f32 = 360.0; + +/// What the Keywording box shows: the keywords (chips to edit), them with the keywords containing +/// them, or what exported files will carry (read only). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeywordingView { + #[default] + Keywords, + Containing, + WillExport, +} diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index e349f694b..25d7cd84c 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -184,7 +184,7 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { } let choice = |ui: &mut egui::Ui, on: &mut bool, n: usize, text: String, widget: &str| { let r = ui.add_enabled(n > 0, egui::Checkbox::new(on, text)); - crate::widgets::register(ui.ctx(), widget, r.rect); + crate::widgets::register(ui, widget, r.rect); }; let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text)); let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); @@ -315,6 +315,28 @@ impl Drop for SyncRun { } } +/// Tests: a synchronize that stays running while the returned sender lives, so a drop that is +/// released when another barrier goes meets a running Synchronize Folder deterministically +/// (without waiting for a real folder's walk). The caller clears `sync_run` when done. +#[cfg(test)] +pub(crate) fn stall_run(app: &mut LightcraftApp, name: &str) -> Box { + let progress = Arc::new(SyncProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone())); + guard.detail(name); + app.sync_run = Some(SyncRun { + name: name.to_string(), + progress, + crashed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + rx, + commit: None, + stopping: false, + not_saved: Vec::new(), + guard, + }); + Box::new(tx) +} + /// Commit what the worker readied (called every frame); when it is done, one undo step and a /// word on what happened. pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs new file mode 100644 index 000000000..3a102a29a --- /dev/null +++ b/crates/ui-egui/src/tests.rs @@ -0,0 +1,87 @@ +mod activity; +mod album_picker; +mod album_remove; +mod appearance; +mod before_after; +mod browse_active; +mod button; +mod canvas_keys; +mod canvas_pan; +mod capture_time; +mod compare; +mod copy_paste_dialogs; +mod crop_rotate; +mod crop_tool; +mod curve; +mod date_picker; +mod dialog_fit; +mod dialog_primary; +mod dialog_settings; +mod dialog_tab; +mod dialogs; +mod docking_acceptance; +mod drop_import; +mod edit_layout; +mod empty_library; +mod error_kinds; +mod filmstrip; +mod focus_point; +mod folder_drop; +mod grading; +mod grid; +mod guided_guides; +mod histogram_drag; +mod info_dialogs; +mod keymap; +mod keyword_files; +mod keyword_list; +mod keyword_set; +mod keyword_sidebar; +mod keywording; +mod labels; +mod launch_library; +mod layout_image; +mod library_problem; +mod lifecycle_037; +mod lightroom_destination; +mod lights_out; +mod mask_effects; +mod masking; +mod masking_layout; +mod menubar; +mod merge_preview; +mod model_setup; +mod offline; +mod open_lightroom; +pub(super) mod panels; +mod preset_favorites; +mod preset_import; +mod preview_limit; +mod print; +mod profile_menu; +mod profiles_layout; +mod progress; +mod quit_unsaved; +mod rating; +mod recheck; +mod rename_person; +mod rules_editor; +mod sam_setup; +mod scroll; +mod slider_reset; +mod slideshow; +mod solo_sections; +mod switch_library; +mod sync; +mod tallies; +mod temp_slider; +mod titlebar; +mod tool_overlay; +mod unsaved; +mod view_cost; +mod wb_selector; +mod zoom; +mod zoom_keys; + +pub(super) mod fixtures; +pub(super) mod render_fixtures; diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests/activity.rs similarity index 95% rename from crates/ui-egui/src/tests_activity.rs rename to crates/ui-egui/src/tests/activity.rs index 862a21ee2..da3b934dd 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests/activity.rs @@ -78,7 +78,7 @@ fn more_than_three_rows_overflow_and_expand() { click(&mut h, "activity:more"); assert_eq!(rows(&h), 5); // expanded, the stack grows to show them all - let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn"); + let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::unique("activity-stack"))).expect("the stack is drawn"); for g in &guards { let r = rect(&h, &format!("activity:row:{}", g.id())); assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id()); @@ -447,13 +447,15 @@ fn batch_merge_runs_each_set_in_turn() { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let mut paths = Vec::new(); - // different exposures, or the import would take set B for duplicates of set A - for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5]), ("C", [-1.0, 1.0, 3.0])] { - for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap().into_iter().enumerate() { - let p = dir.join(format!("{set}_{i}.dng")); - std::fs::write(&p, bytes).unwrap(); - paths.push(p.to_string_lossy().to_string()); - } + // one `bracket_dngs` call renders the procedural scene once for all nine inputs (three calls + // would render it three times); the exposures stay different, or the import would take one + // set for duplicates of another + let evs = [-2.0, 0.0, 2.0, -1.5, 0.5, 2.5, -1.0, 1.0, 3.0]; + let sets = ["A", "A", "A", "B", "B", "B", "C", "C", "C"]; + for (i, (set, bytes)) in sets.into_iter().zip(lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap()).enumerate() { + let p = dir.join(format!("{set}_{}.dng", i % 3)); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); } let mut session = lightcraft_engine::Session::new().with_fs(); let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); @@ -465,7 +467,10 @@ fn batch_merge_runs_each_set_in_turn() { session.execute("library.select", &json!({"ids": ids})).unwrap(); let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - h.settle(T); + // the merge ordering does not need the grid's nine thumbnails; one Detail render is enough + let r = h.request("ui.set", json!({"view": "detail", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(T), "the imported photos and the active preview settle"); let r = crate::menus::run_ui_command(&mut h.app, "merge.hdrBatch", &json!({})).unwrap().unwrap(); assert_eq!(r["sets"], 3); assert_eq!(h.app.merge.batch.len(), 2, "one running, two queued"); @@ -529,7 +534,7 @@ fn rows_added_later_grow_the_stack() { for _ in 0..5 { h.step(); } - let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn"); + let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::unique("activity-stack"))).expect("the stack is drawn"); for g in [&a, &b, &c] { let r = rect(&h, &format!("activity:row:{}", g.id())); assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id()); diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests/album_picker.rs similarity index 92% rename from crates/ui-egui/src/tests_album_picker.rs rename to crates/ui-egui/src/tests/album_picker.rs index 97305cba6..c99f85770 100644 --- a/crates/ui-egui/src/tests_album_picker.rs +++ b/crates/ui-egui/src/tests/album_picker.rs @@ -119,6 +119,48 @@ fn albums_that_would_loop_are_greyed() { assert_eq!(value(&h), json!(null), "but not picked"); } +/// The registered rectangle of `id` in the current frame. +fn rect_of(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// The height of the smallest area (the popup, not the dialog) that holds the search field, as egui keeps it between frames. +fn popup_height(h: &Headless, search: egui::Rect) -> f32 { + let ctx = &h.view.ctx; + let layers = ctx.memory(|m| m.areas().visible_layer_ids()); + layers + .into_iter() + .filter_map(|layer| egui::AreaState::load(ctx, layer.id)) + .filter(|a| a.rect().contains_rect(search)) + .filter_map(|a| a.size.map(|s| s.y)) + .fold(f32::MAX, f32::min) +} + +/// Filtering to nothing shrinks the popup and clearing the filter grows it back (egui#5225), +/// with the search field in the same place throughout. +#[test] +fn the_popup_grows_back_when_the_search_is_cleared() { + let Library { mut h, .. } = library(); + click(&mut h, "albumPicker:rules-0"); + let search = rect_of(&h, "albumPickerSearch:rules-0"); + let full = popup_height(&h, search); + assert!(full > 80.0, "the open popup shows the tree: {full}"); + let r = h.request("ui.text", json!({"text": "zzzz"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let none = popup_height(&h, search); + assert!(none < full - 20.0, "no match shrinks the popup: {none} vs {full}"); + assert_eq!(rect_of(&h, "albumPickerSearch:rules-0"), search, "the search field stays put"); + for _ in 0..4 { + let r = h.request("ui.key", json!({"key": "Backspace"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + let again = popup_height(&h, search); + assert!((again - full).abs() < 1.0, "cleared, the popup is {again} tall, not {full} as when it opened"); + assert_eq!(rect_of(&h, "albumPickerSearch:rules-0"), search, "the search field stays put"); +} + /// After opening a folder, typing still goes to the search: the box keeps the keyboard. #[test] fn typing_after_a_folder_click_still_searches() { diff --git a/crates/ui-egui/src/tests_album_remove.rs b/crates/ui-egui/src/tests/album_remove.rs similarity index 100% rename from crates/ui-egui/src/tests_album_remove.rs rename to crates/ui-egui/src/tests/album_remove.rs diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests/appearance.rs similarity index 100% rename from crates/ui-egui/src/tests_appearance.rs rename to crates/ui-egui/src/tests/appearance.rs diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests/before_after.rs similarity index 93% rename from crates/ui-egui/src/tests_before_after.rs rename to crates/ui-egui/src/tests/before_after.rs index 02ee879a1..ffc376774 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests/before_after.rs @@ -7,6 +7,7 @@ use serde_json::json; use crate::headless::{Headless, RenderGate}; use crate::render::Slot; use crate::state::{BeforeAfter, ViewMode, Zoom}; +use crate::tests_render_fixtures::{HEIGHT, RenderFixture, WIDTH}; use crate::{LightcraftApp, Services}; const T: Duration = Duration::from_secs(20); @@ -133,18 +134,30 @@ fn assert_rect(actual: egui::Rect, expected: egui::Rect) { ); } +type AnimationView = (RenderFixture, Headless, [egui::Rect; 2], std::rc::Rc>); + /// Start with both textures ready, then hold replacement renders so worker timing cannot hide -/// a frame of the transition or replace a cached tile under the assertions. -fn animation_view(mode: BeforeAfter, zoom: Zoom) -> (Headless, [egui::Rect; 2], std::rc::Rc>) { - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); +/// a frame of the transition or replace a cached tile under the assertions. One real 6000×4000 +/// file-backed photo keeps the render work the animation needs, without the demo's neighbours. +fn animation_view(mode: BeforeAfter, zoom: Zoom) -> AnimationView { + let fixture = RenderFixture::new("before-after-animation", 1); + let app = LightcraftApp::new(fixture.session(), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + assert_eq!(h.app.session.catalog.len(), 1, "one real 24 MP photo"); let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); assert_eq!(r["ok"], true, "{r}"); - h.app.ui.before_after = mode; + let id = h.app.session.active().expect("a photo is open"); + let photo = h.app.session.catalog.photo(id).expect("it is in the catalog"); + assert_eq!((photo.width, photo.height), (WIDTH, HEIGHT), "the render fixture is a real 24 MP photo"); + // settle the single photo's source and Main render before the second pane exists: both pane + // jobs then reuse the decoded 24 MP source instead of decoding it twice + h.app.ui.before_after = BeforeAfter::Off; h.app.ui.zoom = zoom; h.app.ui.pan = (0.5, 0.5); h.app.ui.click_zoom = 300; - assert!(h.settle(SETTLE), "baseline renders settled"); + assert!(h.settle(SETTLE), "the single photo's source and Main render settle"); + h.app.ui.before_after = mode; + assert!(h.settle(SETTLE), "both pane renders settled"); let before_id = h.app.renderer.textures[&Slot::Before].tex.id(); let before = h .view @@ -162,7 +175,7 @@ fn animation_view(mode: BeforeAfter, zoom: Zoom) -> (Headless, [egui::Rect; 2], assert!(!before.intersects(after), "pane clips do not overlap"); let open = std::rc::Rc::new(std::cell::Cell::new(false)); h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone()))); - (h, [before, after], open) + (fixture, h, [before, after], open) } fn queue_click(h: &mut Headless, pos: egui::Pos2) { @@ -178,7 +191,7 @@ fn queue_click(h: &mut Headless, pos: egui::Pos2) { } fn check_click_animation(mode: BeforeAfter) { - let (mut h, [before, after], open) = animation_view(mode, Zoom::Fit); + let (_fx, mut h, [before, after], open) = animation_view(mode, Zoom::Fit); let shift = before.center() - after.center(); let fit = h.app.image_rect.expect("Fit image"); let id = h.app.session.active().expect("photo"); @@ -234,7 +247,7 @@ fn both_before_after_panes_use_the_animated_rect_top_bottom() { } fn check_region_animation(mode: BeforeAfter) { - let (mut h, [before, after], open) = animation_view(mode, Zoom::Percent(200.0)); + let (_fx, mut h, [before, after], open) = animation_view(mode, Zoom::Percent(200.0)); let shift = before.center() - after.center(); let start = h.app.image_rect.expect("200 percent image"); let tiles = [(Slot::RegionBefore, before, true), (Slot::Region, after, false)].map(|(slot, pane, is_before)| { diff --git a/crates/ui-egui/src/tests_browse_active.rs b/crates/ui-egui/src/tests/browse_active.rs similarity index 100% rename from crates/ui-egui/src/tests_browse_active.rs rename to crates/ui-egui/src/tests/browse_active.rs diff --git a/crates/ui-egui/src/tests_button.rs b/crates/ui-egui/src/tests/button.rs similarity index 100% rename from crates/ui-egui/src/tests_button.rs rename to crates/ui-egui/src/tests/button.rs diff --git a/crates/ui-egui/src/tests_canvas_keys.rs b/crates/ui-egui/src/tests/canvas_keys.rs similarity index 100% rename from crates/ui-egui/src/tests_canvas_keys.rs rename to crates/ui-egui/src/tests/canvas_keys.rs diff --git a/crates/ui-egui/src/tests_canvas_pan.rs b/crates/ui-egui/src/tests/canvas_pan.rs similarity index 100% rename from crates/ui-egui/src/tests_canvas_pan.rs rename to crates/ui-egui/src/tests/canvas_pan.rs diff --git a/crates/ui-egui/src/tests_capture_time.rs b/crates/ui-egui/src/tests/capture_time.rs similarity index 92% rename from crates/ui-egui/src/tests_capture_time.rs rename to crates/ui-egui/src/tests/capture_time.rs index b5903e053..c604ecad1 100644 --- a/crates/ui-egui/src/tests_capture_time.rs +++ b/crates/ui-egui/src/tests/capture_time.rs @@ -59,7 +59,7 @@ fn shift_preview_says_the_photos_capture_time_cant_be_read() { let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); + // Inspect the notification before settling advances the simulated clock past its lifetime. assert!(h.app.ui.dialog.is_none(), "OK closes the dialog"); let toast = toast(&h); assert!(toast.contains("can't be read"), "OK says the photo was left as it was: {toast:?}"); @@ -92,7 +92,7 @@ fn set_mode_refuses_moving_others_by_an_unreadable_time_and_repairs_it_alone() { open_set(&mut h, &[bad, other]); let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); + // The click is consumed by request; unrelated rendering must not expire its notification. let toast = toast(&h); assert!(toast.contains("can't be read"), "OK says why nothing changed: {toast:?}"); assert!(matches!(h.app.ui.dialog, Some(Dialog::CaptureTime { .. })), "the dialog stays open after the refusal"); @@ -116,7 +116,8 @@ fn set_mode_refuses_moving_others_by_an_unreadable_time_and_repairs_it_alone() { /// button and from the control channel alike; they used to close it without a word. #[test] fn errors_are_shown_and_the_dialog_stays_open() { - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let files = crate::tests_fixtures::SyntheticLibrary::new("capture-time-errors"); + let app = files.app(); let mut h = Headless::new(app, [1300.0, 900.0], 1.0); let id = h.app.session.visible_cloned()[0]; set_captured(&mut h, id, "9999-12-31T12:00:00"); @@ -125,9 +126,10 @@ fn errors_are_shown_and_the_dialog_stays_open() { Some(Dialog::CaptureTime { time, .. }) => *time = "yesterday".into(), other => panic!("Edit Capture Time is not open: {other:?}"), } - h.settle(SETTLE); + assert!(h.settle(SETTLE), "capture-time dialog renders settled"); assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); - h.settle(SETTLE); + // The request consumes the click. Inspect its short-lived notification before waiting + // for unrelated rendering: settling advances the simulated clock and can expire the toast. assert!(toast(&h).contains("not a date"), "OK says the typed time isn't a date: {:?}", toast(&h)); assert!(matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time == "yesterday"), "the dialog stays open with what was typed"); @@ -139,7 +141,7 @@ fn errors_are_shown_and_the_dialog_stays_open() { } other => panic!("Edit Capture Time is not open: {other:?}"), } - h.settle(SETTLE); + assert!(h.settle(SETTLE), "capture-time dialog renders settled"); let r = h.request("ui.dialog.confirm", json!({}), T); assert_ne!(r["ok"], true, "the shift is refused: {r}"); assert!(r.to_string().contains("9999"), "{r}"); diff --git a/crates/ui-egui/src/tests_compare.rs b/crates/ui-egui/src/tests/compare.rs similarity index 100% rename from crates/ui-egui/src/tests_compare.rs rename to crates/ui-egui/src/tests/compare.rs diff --git a/crates/ui-egui/src/tests_copy_paste_dialogs.rs b/crates/ui-egui/src/tests/copy_paste_dialogs.rs similarity index 100% rename from crates/ui-egui/src/tests_copy_paste_dialogs.rs rename to crates/ui-egui/src/tests/copy_paste_dialogs.rs diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests/crop_rotate.rs similarity index 96% rename from crates/ui-egui/src/tests_crop_rotate.rs rename to crates/ui-egui/src/tests/crop_rotate.rs index 71368fba2..7cab2f085 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests/crop_rotate.rs @@ -6,6 +6,7 @@ use std::time::Duration; use serde_json::json; use crate::headless::Headless; +use crate::tests_fixtures::SyntheticLibrary; use crate::{LightcraftApp, Services}; const T: Duration = Duration::from_secs(20); @@ -13,12 +14,26 @@ const SETTLE: Duration = Duration::from_secs(120); fn crop_tool() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + crop_view(app, false) +} + +/// The crop tool on `app`, asserting the setup settle: a pixel comparison must not run on a view +/// whose first render never finished. +fn crop_tool_with(app: LightcraftApp) -> Headless { + crop_view(app, true) +} + +fn crop_view(app: LightcraftApp, assert_settle: bool) -> Headless { let mut h = Headless::new(app, [1200.0, 800.0], 1.0); for (method, params) in [("ui.set", json!({"view": "detail"})), ("engine.execute", json!({"command": "panel.crop"}))] { let r = h.request(method, params, T); assert_eq!(r["ok"], true, "{r}"); } - h.settle(SETTLE); + if assert_settle { + assert!(h.settle(SETTLE), "the crop view settles before the snapshot"); + } else { + h.settle(SETTLE); + } h } @@ -385,11 +400,13 @@ fn the_angle_field_writes_the_angle_like_the_readout() { /// box once rotated: dimmed wedges inside it, doubly dark ones outside, growing with the angle.) #[test] fn rotating_the_crop_dims_nothing_inside_the_box() { - let mut h = crop_tool(); + // a real file-backed photo: the regression is a screen-space overlay, not native-resolution detail + let files = SyntheticLibrary::new("crop-dimming"); + let mut h = crop_tool_with(files.app()); let plain = h.paint(); let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "crop.angle", "value": -45.0}}), T); assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); + assert!(h.settle(SETTLE), "the rotated crop view settles before the snapshot"); assert_eq!(angle(&h), -45.0); let rotated = h.paint(); let img = h.app.image_rect.expect("the photo on screen"); diff --git a/crates/ui-egui/src/tests_crop_tool.rs b/crates/ui-egui/src/tests/crop_tool.rs similarity index 100% rename from crates/ui-egui/src/tests_crop_tool.rs rename to crates/ui-egui/src/tests/crop_tool.rs diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests/curve.rs similarity index 100% rename from crates/ui-egui/src/tests_curve.rs rename to crates/ui-egui/src/tests/curve.rs diff --git a/crates/ui-egui/src/tests_date_picker.rs b/crates/ui-egui/src/tests/date_picker.rs similarity index 100% rename from crates/ui-egui/src/tests_date_picker.rs rename to crates/ui-egui/src/tests/date_picker.rs diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests/dialog_fit.rs similarity index 99% rename from crates/ui-egui/src/tests_dialog_fit.rs rename to crates/ui-egui/src/tests/dialog_fit.rs index 52d412829..e2e9dd4bf 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests/dialog_fit.rs @@ -130,7 +130,7 @@ fn long_dialogs_fit_a_short_window() { fn a_dialog_list_shrinks_to_fit_a_short_window() { let mut h = demo([1000.0, 320.0]); open(&mut h, "app.shortcuts"); - let shrink = h.view.ctx.data(|d| d.get_temp::(egui::Id::new("dialog-list-shrink"))).unwrap_or(0.0); + let shrink = h.view.ctx.data(|d| d.get_temp::(egui::Id::unique("dialog-list-shrink"))).unwrap_or(0.0); assert!(shrink > 0.0, "the list gave up room"); let dialog = rect(&h, "dialog:window"); assert!(dialog.height() <= 320.0, "{dialog:?}"); diff --git a/crates/ui-egui/src/tests_dialog_primary.rs b/crates/ui-egui/src/tests/dialog_primary.rs similarity index 100% rename from crates/ui-egui/src/tests_dialog_primary.rs rename to crates/ui-egui/src/tests/dialog_primary.rs diff --git a/crates/ui-egui/src/tests_dialog_settings.rs b/crates/ui-egui/src/tests/dialog_settings.rs similarity index 93% rename from crates/ui-egui/src/tests_dialog_settings.rs rename to crates/ui-egui/src/tests/dialog_settings.rs index c62bc2f59..950cae6e6 100644 --- a/crates/ui-egui/src/tests_dialog_settings.rs +++ b/crates/ui-egui/src/tests/dialog_settings.rs @@ -67,6 +67,11 @@ fn controls(h: &Headless) -> Vec<(String, egui::Rect)> { h.app.widgets.iter().filter(|(id, _)| named(id)).cloned().collect() } +/// Whether egui has a visible tooltip area, independent of unrelated window text. +fn tooltip_shown(h: &Headless) -> bool { + h.view.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().any(|layer| layer.order == egui::Order::Tooltip)) +} + fn same(a: egui::Rect, b: egui::Rect) -> bool { (a.min - b.min).abs().max_elem() < 0.5 && (a.max - b.max).abs().max_elem() < 0.5 } @@ -184,7 +189,10 @@ fn a_long_library_folder_is_cut_short_and_its_tooltip_has_all_of_it() { ); // And resting the pointer on it shows the whole path assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true); - assert!(h.step_until(T, |h| h.painted_text().iter().any(|text| text.contains(&path))), "the tooltip has the whole path"); + assert!( + h.step_until(T, |h| tooltip_shown(h) && h.painted_text().iter().any(|text| text.contains(&path))), + "the tooltip layer shows the whole path" + ); // And the same holds for the folder Auto Import watches, which stays on one line let watched = h.app.run("library.autoImport", json!({"folder": path})); @@ -256,13 +264,23 @@ fn a_short_path_is_shown_whole_and_has_no_tooltip() { // Given the in-memory library, whose Location is a short sentence let mut h = demo([1600.0, 1000.0]); show(&mut h, "general"); - assert!(h.painted_text().iter().any(|text| text == "In memory — nothing is saved"), "it is all there"); - let before = h.painted_text().len(); - // When the pointer rests on it + let location = "In memory — nothing is saved"; + assert!(h.painted_text().iter().any(|text| text == location), "it is all there"); + assert!(!tooltip_shown(&h), "no tooltip before hovering the location"); assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true); - let _ = h.step_until(Duration::from_secs(2), |h| h.painted_text().len() > before); - // Then nothing more is drawn: there is nothing a tooltip could add - assert_eq!(h.painted_text().len(), before, "no tooltip for a path that is all there"); + // Observe every frame for two simulated seconds beyond the configured tooltip delay. + // A render completing or a toast disappearing may change other text in the window. + let rest_until = h.time() + f64::from(h.view.ctx.global_style().interaction.tooltip_delay) + 2.0; + assert!(!tooltip_shown(&h), "no tooltip when hovering the complete location"); + while h.time() < rest_until { + h.step(); + assert!(!tooltip_shown(&h), "no tooltip for a path that is all there at {}", h.time()); + assert_eq!( + h.painted_text().iter().filter(|text| text.as_str() == location).count(), + 1, + "the full location is drawn once throughout the hover" + ); + } } /// How many pixels of `rect` are not `colour` (one pixel per point here). diff --git a/crates/ui-egui/src/tests_dialog_tab.rs b/crates/ui-egui/src/tests/dialog_tab.rs similarity index 100% rename from crates/ui-egui/src/tests_dialog_tab.rs rename to crates/ui-egui/src/tests/dialog_tab.rs diff --git a/crates/ui-egui/src/tests_dialogs.rs b/crates/ui-egui/src/tests/dialogs.rs similarity index 100% rename from crates/ui-egui/src/tests_dialogs.rs rename to crates/ui-egui/src/tests/dialogs.rs diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests/docking_acceptance.rs similarity index 84% rename from crates/ui-egui/src/tests_docking_acceptance.rs rename to crates/ui-egui/src/tests/docking_acceptance.rs index 1dc97892e..2056103f4 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests/docking_acceptance.rs @@ -44,6 +44,30 @@ fn release(h: &mut Headless, pos: egui::Pos2) { h.step(); } +#[test] +fn window_menu_reopens_closed_panels_in_their_dock_columns() { + use craft_ui::docking::Side; + let mut h = app(); + let columns = |h: &Headless, side| { + crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), side).into_iter().map(|(_, groups)| groups).collect::>() + }; + let end = vec![vec![vec![Panel::Tools]], vec![vec![Panel::Presets]]]; + h.app.run("panel.presets", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Presets opens on Tools' photo side"); + dock(&mut h, json!({"operation":"close","panel":"tools"})); + assert_eq!(h.app.ui.right, RightPanel::None); + h.app.run("panel.edit", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Tools reopens at the window edge"); + let width = crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start).first().unwrap().0; + dock(&mut h, json!({"operation":"close","panel":"library"})); + assert!(!h.app.ui.left_panel); + h.app.run("view.leftPanel", json!({})).unwrap(); + h.step(); + assert_eq!(crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start), [(width, vec![vec![Panel::Library]])]); +} + #[test] fn inactive_same_mode_commands_reveal_without_closing_or_editing() { let mut h = app(); @@ -53,7 +77,7 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() { let undo = h.app.session.undo.len(); let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); h.app.run(command, json!({})).unwrap(); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}"); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools), "{command}"); assert_ne!(h.app.ui.right, RightPanel::None); assert_eq!(h.app.session.undo.len(), undo); assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(), photos); @@ -64,24 +88,37 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() { dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"})); dock(&mut h, json!({"operation":"activate","panel":"library"})); h.app.run("panel.presets", json!({})).unwrap(); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets)); dock(&mut h, json!({"operation":"activate","panel":"library"})); dock(&mut h, json!({"operation":"open","panel":"presets"})); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets)); } #[test] fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() { - let mut h = app(); + // This exercises Canvas input and dock routing, not native-resolution rendering. + // Keep the real file alive until after the headless renderer is dropped. + let photo_files = crate::tests_fixtures::SyntheticLibrary::new("dock-crop-double-click"); + let mut fixture_app = photo_files.app(); + fixture_app.ui.left_panel = true; + let mut h = Headless::new(fixture_app, [1400.0, 1000.0], 1.0); + for _ in 0..5 { + h.step(); + } h.app.ui.view = crate::state::ViewMode::Detail; h.app.run("panel.crop", json!({})).unwrap(); - assert!(h.settle(std::time::Duration::from_secs(120)), "the demo photo settles before pointer input"); + assert!(h.settle(std::time::Duration::from_secs(120)), "the file-backed photo settles before pointer input"); + let active = h.app.session.active().expect("the fixture photo is active"); + assert!( + h.app.renderer.textures.get(&crate::render::Slot::Main).is_some_and(|tex| tex.photo == active && tex.size[0] > 0 && tex.size[1] > 0), + "the actual detail render is present before Canvas input" + ); h.app.ui.presets = true; h.step(); dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"})); dock(&mut h, json!({"operation":"activate","panel":"presets"})); assert_eq!(h.app.ui.right, RightPanel::Crop); - assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools)); + assert!(!crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools)); let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap(); let id = h.app.session.active().unwrap(); let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap(); @@ -94,7 +131,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() { } assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop"); let workspace = h.app.ui.docking.as_ref().unwrap(); - assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback"); + assert!(crate::docking::exposed_in(workspace, Panel::Tools), "tool.done reveals Tools after the Canvas callback"); assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets)); assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library); assert_eq!(h.app.session.active(), Some(id)); @@ -164,7 +201,7 @@ fn dock_tab_keeps_activation_keys_and_global_command_remains_reachable() { h.app.run("panel.crop", json!({})).unwrap(); h.step(); // The test chooses the renderer's actual registered tab, rather than an arbitrary focus owner. - let id = egui::Id::new("lightcraft-docking").with(("tab", Panel::Tools)); + let id = egui::Id::unique("lightcraft-docking").with(("tab", Panel::Tools)); h.view.ctx.memory_mut(|memory| memory.request_focus(id)); h.step(); key(&mut h, egui::Key::Enter); @@ -186,7 +223,7 @@ fn content_text_edit_retains_command_keys_and_typed_text() { h.app.synthetic.push(Event::Text("dock search".into())); h.step(); assert_eq!(h.app.ui.right, RightPanel::Keywords); - assert_eq!(h.view.ctx.data(|data| data.get_temp::(egui::Id::new("keyword-list-filter"))).as_deref(), Some("dock search")); + assert_eq!(h.view.ctx.data(|data| data.get_temp::(egui::Id::unique("keyword-list-filter"))).as_deref(), Some("dock search")); } #[test] @@ -271,8 +308,8 @@ fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keywo #[test] fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); - let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center(); + let divider = egui::Id::unique("lightcraft-docking").with(("column-resize", Some(Panel::Library))); + let from = h.view.ctx.read_response(divider).expect("the Library column's divider is rendered").rect.center(); let before = h.app.ui.docking.as_ref().unwrap().layout.clone(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.step(); @@ -303,7 +340,7 @@ fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { fn held_divider_replacement_route(route: &str) { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); + let divider = egui::Id::unique("lightcraft-docking").with(("column-resize", Some(Panel::Library))); let from = h.view.ctx.read_response(divider).unwrap().rect.center(); let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); diff --git a/crates/ui-egui/src/tests/drop_import.rs b/crates/ui-egui/src/tests/drop_import.rs new file mode 100644 index 000000000..3d8031a69 --- /dev/null +++ b/crates/ui-egui/src/tests/drop_import.rs @@ -0,0 +1,1109 @@ +//! Files dragged from the file manager onto the window: while they hover, the window says what +//! dropping will do; dropping photos or folders opens the import review (unless Settings ▸ Import +//! says to import dropped files directly); a drop while a dialog is open is refused. + +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::pick::PickRequest; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-ui-drop-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn write_png(path: &str, seed: u8) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// A folder dialog that answers "cancelled": enough to make the launch list's New Library button +/// and its name prompt available. +fn services() -> Services { + Services { png: None, pick_folder: Some(Box::new(|| -> Option { None })), ..Default::default() } +} + +/// An empty library, a folder `trip` with two photos, a window big enough for the overlay. +fn app(dir: &Scratch) -> Headless { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/b.png"), 2); + let session = lightcraft_engine::Session::new().with_fs(); + let mut h = Headless::new(LightcraftApp::new(session, services()), [1300.0, 900.0], 1.0); + for _ in 0..4 { + h.step(); + } + h +} + +/// Folder dialogs the host shows "elsewhere": each request is recorded with the sender that +/// answers it, so a test can cancel or choose after the app has moved on to its next UI pass. +type Asked = Arc>)>>>; + +/// A no-library window whose New Library and Open Another… folder dialogs are asynchronous, as on +/// the desktop hosts that run pickers off the UI thread (and in the browser). +fn app_with_picker(dir: &Scratch) -> (Headless, Asked) { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/b.png"), 2); + let session = lightcraft_engine::Session::new().with_fs(); + let mut app = LightcraftApp::new(session, services()); + let asked: Asked = Default::default(); + let a = asked.clone(); + app.services.picker = Some(Box::new(move |req| { + let (tx, rx) = std::sync::mpsc::channel(); + a.lock().unwrap().push((req, tx)); + Ok(rx) + })); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + for _ in 0..4 { + h.step(); + } + (h, asked) +} + +fn asked_dialogs(asked: &Asked) -> usize { + asked.lock().unwrap().len() +} + +/// Answer the oldest folder dialog: `paths` empty cancels it. +fn answer_picker(asked: &Asked, paths: Vec) { + let sender = asked.lock().unwrap().remove(0).1; + sender.send(paths).unwrap(); +} + +/// A library folder that can be chosen by Open Another…, made outside the no-library app. +fn library_at(dir: &Scratch, rel: &str) -> String { + let path = dir.path(rel); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&path, false).unwrap(); + session.close_library().unwrap(); + path +} + +fn drop_files(h: &mut Headless, paths: &[String], hover: bool) { + let r = h.request("ui.dropFiles", json!({"paths": paths, "hover": hover}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..3 { + h.step(); + } +} + +/// The same window with a library already open, so a launch list over it is the real "over" +/// case rather than a list with no library behind it. +fn app_with_library(dir: &Scratch) -> Headless { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/b.png"), 2); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.path("library"), false).unwrap(); + let mut h = Headless::new(LightcraftApp::new(session, services()), [1300.0, 900.0], 1.0); + for _ in 0..4 { + h.step(); + } + h +} + +/// Draw the launch list (`over_open_library`: Alt-at-launch over the library that opened, else the +/// list with no library behind it). +fn draw_launch_list(h: &mut Headless, over_open_library: bool) { + let mut problem = crate::panels::library_problem::LibraryProblem::choose_later(&h.app.ui.settings); + if over_open_library && let Some(c) = problem.chooser.as_mut() { + c.over = Some(h.app.session.library_generation()); + } + h.app.library_problem = Some(problem); + assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen")), "the launch list is drawn"); +} + +/// The launch list as Alt-at-launch shows it over the library that opened. +fn show_launch_list(h: &mut Headless) { + draw_launch_list(h, true); + assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the launch list owns the drop barrier"); +} + +/// The same list with no library open: photos still go through the deliberate hold path. +fn show_no_library_launch_list(h: &mut Headless) { + draw_launch_list(h, false); + assert!(!crate::panels::library_problem::blocks_file_drops(&h.app), "with no library open the drop path runs"); +} + +fn user_presets(h: &Headless) -> usize { + h.app.session.presets.iter().filter(|p| !p.builtin).count() +} + +fn overlay(h: &Headless) -> bool { + h.app.widgets.iter().any(|(w, _)| w == "dropOverlay") +} + +fn said(h: &Headless, word: &str) -> bool { + h.painted_text().iter().any(|t| t.contains(word)) +} + +fn photos(h: &Headless) -> usize { + h.app.session.catalog.photos().count() +} + +#[test] +fn hovering_photos_says_they_will_be_reviewed_and_lets_go_when_they_leave() { + let dir = Scratch::new("hover"); + let mut h = app(&dir); + assert!(!overlay(&h), "nothing is dragged over the window"); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], true); + assert!(overlay(&h), "the window shows where to drop"); + assert!(said(&h, "review 2 items"), "{:?}", h.painted_text()); + drop_files(&mut h, &[], true); + assert!(!overlay(&h), "the files left the window"); +} + +#[test] +fn hovering_presets_says_they_will_be_imported_as_presets() { + let dir = Scratch::new("hover-presets"); + let mut h = app(&dir); + std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); + drop_files(&mut h, &[dir.path("warm.lcpreset")], true); + assert!(overlay(&h)); + assert!(said(&h, "1 preset"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "presets are not reviewed"); +} + +#[test] +fn hovering_unknown_files_says_only_that_dropping_imports() { + let dir = Scratch::new("hover-unknown"); + let mut h = app(&dir); + drop_files(&mut h, &[String::new()], true); + assert!(overlay(&h)); + assert!(said(&h, "Drop to import"), "{:?}", h.painted_text()); +} + +#[test] +fn dropping_a_folder_opens_the_import_review_and_imports_nothing_yet() { + let dir = Scratch::new("review"); + let mut h = app(&dir); + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens"); + let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 2); + assert_eq!(photos(&h), 0, "nothing is imported until the review is confirmed"); + assert!(!overlay(&h), "the files were let go"); +} + +#[test] +fn dropping_imports_directly_when_settings_say_so() { + let dir = Scratch::new("direct"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.step_until(SETTLE, |h| photos(h) == 2 && h.app.import.is_none()), "both photos arrive"); + assert!(h.app.ui.dialog.is_none(), "no review"); +} + +#[test] +fn a_drop_while_a_dialog_is_open_leaves_the_dialog_and_imports_nothing() { + let dir = Scratch::new("dialog"); + let mut h = app(&dir); + h.app.ui.dialog = Some(Dialog::WhatsNew); + drop_files(&mut h, &[dir.path("trip/a.png")], true); + assert!(overlay(&h), "the window still answers the hover"); + assert!(said(&h, "Close the open dialog"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("trip")], false); + for _ in 0..30 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the open dialog stays"); + assert!(h.app.scan.is_none() && h.app.import.is_none(), "no scan or import started"); + assert_eq!(photos(&h), 0); + assert!(h.app.ui.toast.is_some(), "the person is told why"); +} + +/// A prompt owns the library and the top layer: a drop while it is up stays queued (it is not +/// imported behind the prompt), and the drop-hover card is not painted above it. +#[test] +fn a_drop_and_its_hover_card_wait_behind_a_notice_prompt() { + let dir = Scratch::new("prompt"); + let mut h = app(&dir); + h.app.notices.push("The settings file could not be read and was reset.".into()); + h.step(); + h.step(); + assert!(h.app.widgets.iter().any(|(w, _)| w == "button:noticeOk"), "the notice is laid out"); + drop_files(&mut h, &[dir.path("trip")], true); + assert!(!overlay(&h), "the drop-hover Tooltip layer must not cover the prompt"); + assert!(h.app.widgets.iter().any(|(w, _)| w == "button:noticeOk"), "the prompt still owns the window"); + drop_files(&mut h, &[dir.path("trip")], false); + assert_eq!(photos(&h), 0, "nothing was imported behind the prompt"); + assert!(h.app.ui.dialog.is_none() && h.app.import.is_none() && h.app.scan.is_none(), "the drop was not handled yet"); + assert_eq!(h.app.dropped_files.len(), 1, "the dropped folder is queued until the prompt is gone"); + assert_eq!(h.app.notices.len(), 1, "the notice is still up"); + let r = h.request("ui.clickWidget", json!({"id": "button:noticeOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.notices.is_empty(), "OK dismisses the notice"); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the queued drop opens its review after the prompt"); + assert_eq!(photos(&h), 0, "the review still imports nothing before it is confirmed"); +} + +/// A photo dropped while the launch list is over an already open library must wait in the queue: +/// importing it behind the list would add it to the library the list is asking to replace or keep. +#[test] +fn a_photo_drop_waits_behind_the_launch_list_over_an_open_library() { + let dir = Scratch::new("chooser-photo"); + let mut h = app_with_library(&dir); + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("trip/a.png")], true); + assert!(!overlay(&h), "the drop card must not promise an import above the launch list"); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert!(h.app.ui.dialog.is_none(), "no import review opens behind the launch list"); + assert!(h.app.scan.is_none() && h.app.import.is_none(), "no scan or import starts behind the launch list"); + assert_eq!(photos(&h), 0, "nothing is imported behind the launch list"); + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list to go"); + // Esc keeps the library that is open (the launch list's own Keep), then the queued drop runs + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "the launch list closes"); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the queued drop opens its review"); + assert_eq!(photos(&h), 0, "the review still imports nothing before it is confirmed"); + assert!(h.app.dropped_files.is_empty(), "the queued drop was consumed once the list was gone"); +} + +/// Preset drops run a command directly (no review), so the barrier must hold those too: a preset +/// imported behind the list would change the open library the person has not chosen to keep. +#[test] +fn a_preset_drop_waits_behind_the_launch_list_over_an_open_library() { + let dir = Scratch::new("chooser-preset"); + let mut h = app_with_library(&dir); + std::fs::write(dir.path("warm.lcpreset"), r#"{"id": "user.warm", "name": "Warm", "group": "Test", "settings": {"light": {"exposure": 0.5}}}"#) + .unwrap(); + let before = user_presets(&h); + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("warm.lcpreset")], false); + for _ in 0..10 { + h.step(); + } + assert_eq!(user_presets(&h), before, "no preset is imported behind the launch list"); + assert_eq!(h.app.dropped_files.len(), 1, "the preset waits for the launch list to go"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list"); + assert!(h.step_until(SETTLE, |h| user_presets(h) == before + 1), "the queued preset imports once the list is gone"); + assert!(h.app.dropped_files.is_empty()); +} + +/// A queued drop must survive the launch list hiding itself behind its own New Library name prompt: +/// that prompt does not make the list stop owning the library. Cancelling the prompt brings the +/// list back; only after Keep (Esc) does the drop import. +#[test] +fn a_queued_photo_drop_survives_the_choosers_new_library_prompt_and_cancel() { + let dir = Scratch::new("chooser-new-cancel"); + let mut h = app_with_library(&dir); + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list"); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name"); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits behind the name prompt too"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. })), "the prompt stays open"); + assert!(h.app.scan.is_none() && h.app.import.is_none(), "nothing starts behind the prompt"); + assert_eq!(photos(&h), 0); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.ui.dialog.is_none()), "Cancel closes the name prompt"); + for _ in 0..5 { + h.step(); + } + assert!(h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen"), "the launch list is back"); + assert_eq!(h.app.dropped_files.len(), 1, "cancel kept the queued drop"); + assert!(h.app.ui.dialog.is_none(), "no review opens behind the returning list"); + assert_eq!(photos(&h), 0); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list"); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the queued drop opens its review"); + assert!(h.app.dropped_files.is_empty(), "the queued drop was consumed once the list was gone"); +} + +/// With no library open a drop during the list's New Library name prompt would once have hit the +/// generic "close the open dialog" refusal and been lost. It now waits for the prompt to close, +/// then goes through the deliberate paths: photos are held for the library chosen, presets refused. +#[test] +fn a_drop_during_the_no_library_name_prompt_is_held_or_refused_after_cancel() { + let dir = Scratch::new("newlib-prompt-drop"); + std::fs::write(dir.path("warm.lcpreset"), r#"{"id": "user.warm", "name": "Warm", "group": "Test", "settings": {"light": {"exposure": 0.5}}}"#) + .unwrap(); + let mut h = app(&dir); + show_no_library_launch_list(&mut h); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name"); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], false); + assert_eq!(h.app.dropped_files.len(), 2, "the drop waits behind the name prompt"); + assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(0), "nothing is held behind the prompt"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.ui.dialog.is_none()), "Cancel closes the name prompt"); + assert!(h.step_until(SETTLE, |h| h.app.dropped_files.is_empty()), "the drop is consumed once the prompt is gone"); + assert_eq!( + h.app.library_problem.as_ref().map(|p| p.pending_import.clone()), + Some(vec![dir.path("trip/a.png")]), + "the photo waits for the library chosen" + ); + assert_eq!(user_presets(&h), 0, "the preset is refused, not imported into the temporary session"); + assert_eq!(photos(&h), 0, "nothing is imported into the temporary session"); +} + +/// The no-library New Library flow asks for a name, then for a place on the host's asynchronous +/// folder picker. A drop queued during either step waits: it must not be consumed before the new +/// library exists. Confirming the picker opens the library and the queued photo imports into it. +#[test] +fn a_queued_photo_drop_survives_the_async_new_library_picker_and_imports_on_confirmation() { + let dir = Scratch::new("newlib-async-confirm"); + let (mut h, asked) = app_with_picker(&dir); + show_no_library_launch_list(&mut h); + h.app.ui.settings.import_dropped_directly = true; + let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up"); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(asked_dialogs(&asked), 1, "one asynchronous folder dialog was started"); + assert!(h.app.session.library.is_none(), "no library exists until the picker answers"); + assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the folder picker still owns the drop barrier"); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker"); + assert!(h.app.ui.dialog.is_none(), "the name prompt is gone; only the picker is up"); + assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(0), "nothing is held while the picker is still open"); + assert!(h.app.scan.is_none() && h.app.import.is_none() && photos(&h) == 0, "nothing starts behind the picker"); + + let parent = dir.path("new-library-parent"); + std::fs::create_dir_all(&parent).unwrap(); + answer_picker(&asked, vec![parent]); + assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker answered"); + assert!(h.step_until(SETTLE, |h| h.app.session.library.is_some()), "the new library opens"); + assert!( + h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()), + "the queued photo imports into the new library" + ); + assert_eq!(user_presets(&h), 0); +} + +/// Cancelling the asynchronous place picker leaves the launch list up. The queued photo then takes +/// the no-library hold path (kept for the library chosen) instead of being lost behind the picker. +#[test] +fn a_queued_drop_survives_the_async_new_library_picker_cancel_then_takes_the_no_library_path() { + let dir = Scratch::new("newlib-async-cancel"); + std::fs::write(dir.path("warm.lcpreset"), r#"{"id": "user.warm", "name": "Warm", "group": "Test", "settings": {"light": {"exposure": 0.5}}}"#) + .unwrap(); + let (mut h, asked) = app_with_picker(&dir); + show_no_library_launch_list(&mut h); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up"); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], false); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.dropped_files.len(), 2, "both files wait through the picker"); + assert!(h.app.session.library.is_none()); + + answer_picker(&asked, vec![]); + assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker was cancelled"); + assert!(h.step_until(SETTLE, |h| h.app.dropped_files.is_empty()), "the drop takes the no-library path now"); + assert!(h.app.session.library.is_none(), "cancel created no library"); + assert!(h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen"), "the launch list is still up"); + assert_eq!( + h.app.library_problem.as_ref().map(|p| p.pending_import.clone()), + Some(vec![dir.path("trip/a.png")]), + "the photo waits for the library chosen" + ); + assert_eq!(user_presets(&h), 0, "the preset is refused with no library"); + assert_eq!(photos(&h), 0, "nothing is imported into the temporary session"); +} + +/// Open Another… has the same asynchronous folder picker with no library open. A queued drop must +/// wait for it, then import into the library the person opens. +#[test] +fn a_queued_photo_drop_survives_the_async_open_library_picker_and_imports_on_confirmation() { + let dir = Scratch::new("open-async-confirm"); + let target = library_at(&dir, "chosen-library"); + let (mut h, asked) = app_with_picker(&dir); + show_no_library_launch_list(&mut h); + h.app.ui.settings.import_dropped_directly = true; + let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up"); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(asked_dialogs(&asked), 1); + assert!(h.app.session.library.is_none()); + assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the folder picker still owns the drop barrier"); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker"); + + answer_picker(&asked, vec![target.clone()]); + assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker answered"); + assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "opening the library ends the launch list"); + assert!( + h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()), + "the queued photo imports into the opened library" + ); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(target)); +} + +/// A library opened from the asynchronous picker can start files that were already waiting in +/// `pending_import` (the command line, an earlier no-library drop). A drop queued while the picker +/// was up must not be handed to the ordinary drop path while that import runs: it waits for the +/// pending import to end and is imported too, instead of being refused and lost. +#[test] +fn a_queued_drop_waits_for_the_pending_import_started_when_the_picker_opens_the_library() { + let dir = Scratch::new("pending-import-picker"); + let target = library_at(&dir, "chosen-library"); + let (mut h, asked) = app_with_picker(&dir); + show_no_library_launch_list(&mut h); + h.app.ui.settings.import_dropped_directly = true; + let pending = dir.path("trip/a.png"); + h.app.library_problem.as_mut().unwrap().pending_import.push(pending); + + let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up"); + drop_files(&mut h, &[dir.path("trip/b.png")], false); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker"); + assert!(h.app.session.library.is_none()); + + answer_picker(&asked, vec![target.clone()]); + assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker answered"); + assert!(h.step_until(SETTLE, |h| photos(h) == 1), "the pending import arrives first"); + assert!(h.app.import.is_some(), "the pending import still runs when the drop is released"); + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the pending import instead of being refused"); + assert!( + h.step_until(SETTLE, |h| photos(h) == 2 && h.app.dropped_files.is_empty() && h.app.import.is_none()), + "both the pending file and the queued drop are imported (photos: {}, queued: {:?})", + photos(&h), + h.app.dropped_files.len() + ); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(target)); +} + +/// A drop queued behind the launch list may find another dialog open when the list goes (the +/// person opened one while the barrier was up). It waits for that dialog instead of taking the +/// ordinary "close the open dialog" refusal, so it is not lost. +#[test] +fn a_queued_drop_waits_for_a_dialog_opened_before_the_launch_list_closes() { + let dir = Scratch::new("deferred-dialog"); + let mut h = app_with_library(&dir); + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list"); + h.app.library_problem = None; + h.app.ui.dialog = Some(Dialog::WhatsNew); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the dialog stays"); + assert_eq!(h.app.dropped_files.len(), 1, "the deferred drop is not refused while the dialog is open"); + assert!(h.app.scan.is_none() && h.app.import.is_none(), "nothing starts behind the dialog"); + h.app.ui.dialog = None; + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the drop opens its review once the dialog is closed"); + assert!(h.app.dropped_files.is_empty(), "the deferred drop was consumed only when safe"); +} + +/// A drop queued behind the launch list may be released while a Synchronize Folder runs. It +/// waits for that run to finish instead of taking `import::start_paths`'s "A folder is being +/// synchronized" refusal and being lost. A drop made while no barrier was up still gets that +/// refusal (see `tests_sync::a_synchronize_and_an_import_never_run_at_once`). +#[test] +fn a_queued_drop_waits_for_a_running_synchronize_instead_of_being_refused() { + let dir = Scratch::new("deferred-sync"); + let mut h = app_with_library(&dir); + h.app.ui.settings.import_dropped_directly = true; + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list"); + let _run = crate::sync::stall_run(&mut h.app, "trip"); + assert!(h.app.sync_run.is_some(), "the synchronize is running"); + h.app.library_problem = None; + for _ in 0..10 { + h.step(); + } + assert!(h.app.sync_run.is_some(), "the synchronize still runs"); + assert_eq!(h.app.dropped_files.len(), 1, "the deferred drop waits for the running synchronize instead of being refused"); + assert!(!said(&h, "A folder is being synchronized"), "the deferred drop is not refused: {:?}", h.painted_text()); + assert_eq!(photos(&h), 0, "nothing is imported during the synchronize"); + h.app.sync_run = None; + assert!( + h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()), + "the queued drop imports once the synchronize is done (queued: {:?}, photos: {})", + h.app.dropped_files, + photos(&h) + ); +} + +/// The same for a Lightroom catalog import: a drop queued behind the launch list waits while the +/// task owns the catalog, instead of being refused with "Finish Lightroom catalog import…". +#[test] +fn a_queued_drop_waits_for_a_running_lightroom_import_instead_of_being_refused() { + let dir = Scratch::new("deferred-lightroom"); + let mut h = app_with_library(&dir); + h.app.ui.settings.import_dropped_directly = true; + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list"); + let task = crate::lightroom_import::stall_import(&mut h.app); + h.app.library_problem = None; + for _ in 0..10 { + h.step(); + } + assert!(h.app.lightroom.is_some(), "the Lightroom import still runs"); + assert_eq!(h.app.dropped_files.len(), 1, "the deferred drop waits for the Lightroom import instead of being refused"); + assert!(!said(&h, "Finish Lightroom catalog import"), "the deferred drop is not refused: {:?}", h.painted_text()); + assert_eq!(photos(&h), 0, "nothing is imported during the Lightroom import"); + drop(task); + assert!(h.step_until(SETTLE, |h| h.app.lightroom.is_none()), "the Lightroom task ends"); + // the stub's disconnect reports a stopped task and opens its result window; close it so the + // queued drop is released (a real import's result window is another dialog the guard waits for) + h.app.ui.dialog = None; + assert!( + h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()), + "the queued drop imports once the Lightroom task is done (queued: {:?}, photos: {})", + h.app.dropped_files, + photos(&h) + ); +} + +/// Cancelling Open Another…'s asynchronous picker keeps the launch list up; the queued photo is +/// then held for the library chosen rather than dropped behind the cancelling picker. +#[test] +fn a_queued_photo_drop_survives_the_async_open_library_picker_cancel() { + let dir = Scratch::new("open-async-cancel"); + let (mut h, asked) = app_with_picker(&dir); + show_no_library_launch_list(&mut h); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up"); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker"); + + answer_picker(&asked, vec![]); + assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker was cancelled"); + assert!(h.step_until(SETTLE, |h| h.app.dropped_files.is_empty()), "the drop takes the no-library path now"); + assert!(h.app.session.library.is_none()); + assert!(h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen"), "the launch list is still up"); + assert_eq!( + h.app.library_problem.as_ref().map(|p| p.pending_import.clone()), + Some(vec![dir.path("trip/a.png")]), + "the photo waits for the library chosen" + ); + assert_eq!(photos(&h), 0); +} + +/// A valid face-model drop on a no-library launch list or error window must not open its licence +/// dialog behind that window; it is refused like a preset, while photos still take the hold path. +#[test] +fn a_model_drop_on_a_no_library_window_does_not_open_its_dialog_behind_it() { + let dir = Scratch::new("model-window"); + std::fs::write(dir.path("face.onnx"), lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); + for (tag, problem) in [ + ("error", crate::panels::library_problem::LibraryProblem::new(dir.path("Gone"), "not found")), + ("chooser", crate::panels::library_problem::LibraryProblem::choose_later(&crate::state::AppSettings::default())), + ] { + let mut h = app(&dir); + h.app.library_problem = Some(problem); + for _ in 0..4 { + h.step(); + } + drop_files(&mut h, &[dir.path("face.onnx")], true); + assert!(said(&h, "drop the model again"), "{tag}: the card says the model waits: {:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("face.onnx")], false); + for _ in 0..10 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "{tag}: no face model dialog behind the window"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("drop the model again"), "{tag}: {toast}"); + } +} + +/// A model dropped over the launch list is held like a photo/preset, then opens its licence dialog +/// once the list is kept away. +#[test] +fn a_model_drop_waits_behind_the_launch_list_and_opens_after_keep() { + let dir = Scratch::new("chooser-model"); + std::fs::write(dir.path("face.onnx"), lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); + let mut h = app_with_library(&dir); + h.app.session.face_models_dir = Some(std::path::PathBuf::from(dir.path("models"))); + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("face.onnx")], false); + for _ in 0..10 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no licence dialog opens behind the launch list"); + assert_eq!(h.app.dropped_files.len(), 1, "the model waits for the launch list to go"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list"); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::FaceModel { .. }))), "the queued model opens its licence dialog"); + assert!(h.app.dropped_files.is_empty()); +} + +/// A failed switch leaves the launch list showing its error over the library that stayed open +/// (`Chooser { over: Some(generation), .. }`, not a separate error window). A queued drop must wait +/// there too: importing it would change the library the person has not chosen to keep. +#[test] +fn a_photo_drop_waits_behind_the_choosers_failed_switch_error_until_keep() { + let dir = Scratch::new("failed-switch"); + let mut h = app_with_library(&dir); + let mut problem = crate::panels::library_problem::LibraryProblem::choose_later(&h.app.ui.settings); + let generation = h.app.session.library_generation(); + let Some(c) = problem.chooser.as_mut() else { panic!("the launch list has its chooser") }; + c.over = Some(generation); + problem.path = dir.path("Gone"); + problem.error = format!("{}: the library is no longer there", dir.path("Gone")); + h.app.library_problem = Some(problem); + assert!( + h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen")), + "the launch list is drawn over the library that stayed open" + ); + assert!(said(&h, "no longer there"), "the failed switch's error is shown: {:?}", h.painted_text()); + assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the failed-switch list owns the drop barrier"); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the failed-switch list"); + assert!(h.app.ui.dialog.is_none(), "no import review opens behind the list"); + assert!(h.app.scan.is_none() && h.app.import.is_none(), "nothing starts behind the list"); + assert_eq!(photos(&h), 0); + // Esc is the list's Keep: it closes the list and leaves the library that stayed open. + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list"); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the queued drop opens after Keep"); + assert!(h.app.dropped_files.is_empty()); + assert_eq!(photos(&h), 0, "the review still imports nothing before it is confirmed"); +} + +/// A drop queued while the launch list is up belongs to the library the person then opens with +/// its Open button: the switch must not drop it, and it imports into the new library. +#[test] +fn a_queued_photo_drop_imports_into_the_library_opened_from_the_list() { + let dir = Scratch::new("switch-queue"); + // Made (and flushed) by a session that is dropped before the app opens it: `close_library` + // saves but keeps the library and its OS lock until the session is dropped, and the launch + // list's Open would then be refused with "already open" by this same process's other descriptor. + let second = library_at(&dir, "second"); + let mut h = app_with_library(&dir); + h.app.ui.settings.import_dropped_directly = true; + h.app.ui.settings.library_path = dir.path("library"); + h.app.ui.settings.recent_libraries = vec![dir.path("library"), second.clone()]; + show_launch_list(&mut h); + drop_files(&mut h, &[dir.path("trip/a.png")], false); + assert_eq!(h.app.dropped_files.len(), 1); + let r = h.request("ui.clickWidget", json!({"id": "libraryRecent:1"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).and_then(|c| c.selected), Some(1), "the second library is selected"); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!( + h.step_until(SETTLE, |h| h.app.library_problem.is_none()), + "the switch ends the launch list; problem: {:?}", + h.app.library_problem.as_ref().map(|p| (&p.path, &p.error)) + ); + assert!( + h.step_until(SETTLE, |h| h.app.import.is_none() && photos(h) == 1), + "the queued drop imports into the opened library (queued: {:?}, photos: {}, import running: {})", + h.app.dropped_files, + photos(&h), + h.app.import.is_some() + ); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(second)); + assert!(h.app.dropped_files.is_empty()); +} + +/// The scan of a dropped folder finishing while another dialog is open (the face model's licence, Settings…) +/// keeps that dialog; the review opens, with the scan's photos, once it is closed. +#[test] +fn a_finished_scan_waits_for_the_open_dialog_before_opening_the_review() { + let dir = Scratch::new("waits"); + let mut h = app(&dir); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the dialog the person had open stays"); + h.app.ui.dialog = None; + assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens once the dialog is closed"); + let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 2, "the scan's photos were kept"); +} + +/// A scan that finishes behind Settings, which then opens another library: the review of the first +/// library's scan doesn't open over the second one once Settings closes, and nothing is imported into it. +#[test] +fn a_waiting_review_is_dropped_when_another_library_opens() { + let dir = Scratch::new("switch"); + let mut h = app(&dir); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::Settings { tab: "general".into() }); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + h.step(); + let stale = h.app.review_pending.clone().expect("the review waits behind Settings"); + h.app.run("app.openLibrary", json!({"path": dir.path("other")})).unwrap(); + h.app.ui.dialog = None; + for _ in 0..10 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review from the other library opens"); + assert!(h.app.review_pending.is_none()); + // a review confirmed after the switch (an agent holding it) imports nothing either + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &stale).is_err()); + for _ in 0..10 { + h.step(); + } + assert!(h.app.import.is_none(), "nothing is imported"); + assert_eq!(photos(&h), 0); +} + +/// A review waiting behind another dialog counts as an import in progress: a direct drop meanwhile is +/// refused, and the review's OK never replaces an import that runs when it is confirmed. +#[test] +fn a_waiting_review_never_replaces_a_running_import() { + let dir = Scratch::new("pending-running"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + write_png(&dir.path("more/c.png"), 3); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + h.step(); + assert!(h.app.review_pending.is_some(), "the review waits behind the dialog"); + assert!(crate::import::start_paths(&mut h.app, vec![dir.path("more")]).is_err(), "no second import starts"); + assert!(h.app.import.is_none()); + h.app.ui.dialog = None; + h.step(); + let Some(review) = h.app.ui.dialog.clone() else { panic!("the review opens once the dialog is closed") }; + assert!(matches!(review, Dialog::Import { .. })); + // an import that started anyway (a Local folder browse, say) is the one that runs + h.app.import = Some(crate::import::ImportTask::new(vec![dir.path("more")], json!({"mode": "add"}), 0, false)); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &review).is_err(), "the review's OK is refused"); + assert_eq!(h.app.import.as_ref().map(|t| t.total), Some(1), "the running import stays"); + assert!(h.step_until(SETTLE, |h| h.app.import.is_none()), "it runs to the end"); + assert_eq!(photos(&h), 1, "only the running import's photo arrives"); +} + +/// A drop while an import runs would open a review whose OK replaces the running import: it is +/// refused instead, and the card says so. +#[test] +fn a_drop_while_an_import_runs_is_refused() { + let dir = Scratch::new("running"); + let mut h = app(&dir); + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan(&mut h.app); + drop_files(&mut h, &[dir.path("more/c.png")], true); + assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("more")], false); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review opened"); + assert!(h.app.scan.is_some() && h.app.import.is_none(), "the running scan was left alone and nothing else started"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + assert_eq!(photos(&h), 0); +} + +/// An import that is running stays the one that runs: a direct drop doesn't start a second. +#[test] +fn a_direct_drop_while_an_import_runs_is_refused_too() { + let dir = Scratch::new("running-direct"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan(&mut h.app); + drop_files(&mut h, &[dir.path("more")], false); + assert!(h.app.import.is_none(), "no second import"); + assert!(h.app.ui.toast.is_some()); +} + +/// A Local folder browse scan counts too: a review-first drop would be rejected by `open`, and a +/// direct drop would be replaced when the browse completes. +#[test] +fn a_drop_while_a_browse_scan_runs_is_refused_in_both_modes() { + for direct in [false, true] { + let dir = Scratch::new("running-browse"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = direct; + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan_of(&mut h.app, true); + drop_files(&mut h, &[dir.path("more/c.png")], true); + assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("more")], false); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "direct={direct}: nothing started"); + assert!(h.app.scan.is_some(), "the browse was left alone"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + } +} + +/// While a Lightroom catalog import is pending the drop is refused, and the card says so instead of +/// promising a review. +#[test] +fn hovering_photos_while_a_lightroom_import_is_pending_says_they_will_be_refused() { + let dir = Scratch::new("lightroom"); + let mut h = app(&dir); + let _task = crate::lightroom_import::stall_import(&mut h.app); + drop_files(&mut h, &[dir.path("trip")], true); + assert!(said(&h, "Finish Lightroom catalog import"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.app.ui.dialog.is_none(), "no review opened"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + assert_eq!(photos(&h), 0); +} + +/// A script is not told `ok` for the part of a `paths` list that was strings. +#[test] +fn drop_files_with_a_non_string_path_is_rejected_and_drops_nothing() { + let dir = Scratch::new("badpaths"); + let mut h = app(&dir); + let r = h.request("ui.dropFiles", json!({"paths": [dir.path("trip"), 7]}), T); + assert_eq!(r["ok"], false, "{r}"); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "nothing was dropped"); + assert_eq!(photos(&h), 0); +} + +/// No library open yet (the window of a library that failed to open, or the launch list): the +/// card says the photos wait, and a drop keeps them for the library chosen instead of importing +/// them into the session that library replaces. +#[test] +fn photos_dropped_with_no_library_open_wait_for_one() { + let dir = Scratch::new("no-library"); + let mut h = app(&dir); + h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new(dir.path("Gone"), "not found")); + std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], true); + assert!(said(&h, "once a library is open"), "{:?}", h.painted_text()); + assert!(said(&h, "drop the presets again") && !said(&h, "1 preset"), "{:?}", h.painted_text()); + drop_files(&mut h, &[], true); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], false); + assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(2)); + assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "no review, no import into the temporary session"); + assert_eq!(photos(&h), 0); + // a preset belongs to a library: refused, not imported into the session the library replaces + h.app.ui.toast = None; + drop_files(&mut h, &[dir.path("warm.lcpreset")], false); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("drop the presets again"), "{toast}"); +} + +/// Finish the protected batch through the ordinary review/direct path, checking exact provenance. +fn finish_mixed_drop_batch(h: &mut Headless, expected: &[String], direct: bool) { + if !direct { + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the protected batch reaches review"); + let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + let mut actual = opts.candidates.iter().map(|c| c.path.clone()).collect::>(); + actual.sort(); + let mut wanted = expected.to_vec(); + wanted.sort(); + assert_eq!(actual, wanted, "review contains exactly the protected arrivals"); + assert_eq!(photos(h), 0, "review mode still requires confirmation"); + let reply = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(reply["ok"], true, "{reply}"); + } + assert!(h.step_until(SETTLE, |h| photos(h) == expected.len() && !crate::import::running(&h.app)), "the protected batch imports"); + let mut paths = h + .app + .session + .catalog + .photos() + .map(|p| match &p.source { + lightcraft_catalog::Source::File { path } => path.clone(), + other => panic!("unexpected imported source: {other:?}"), + }) + .collect::>(); + paths.sort(); + let mut wanted = expected.to_vec(); + wanted.sort(); + assert_eq!(paths, wanted, "only protected files entered the catalog"); + assert!(h.app.dropped_files.is_empty(), "the protected arrivals were consumed"); + let revision = h.app.session.catalog.revision; + // Inspect every later pass, not only the final catalog count: duplicate import suppression + // must not disguise replay of a consumed raw-input drop. + for _ in 0..60 { + h.step(); + assert!(!crate::import::running(&h.app), "no replay starts another scan/import"); + assert!(h.app.ui.dialog.is_none(), "no replay opens another review"); + assert!(h.app.dropped_files.is_empty()); + assert_eq!(photos(h), expected.len()); + assert_eq!(h.app.session.catalog.revision, revision, "no later import mutates the catalog"); + } +} + +#[test] +fn a_new_drop_outside_the_barrier_is_refused_while_an_older_drop_waits_for_a_dialog() { + for direct in [false, true] { + let dir = Scratch::new("mixed-provenance-dialog"); + let mut h = app_with_library(&dir); + h.app.ui.settings.import_dropped_directly = direct; + let a = dir.path("trip/a.png"); + let b = dir.path("trip/b.png"); + show_launch_list(&mut h); + drop_files(&mut h, std::slice::from_ref(&a), false); + assert_eq!(h.app.dropped_files.len(), 1); + h.app.library_problem = None; + h.app.ui.dialog = Some(Dialog::WhatsNew); + h.step(); + assert!(!crate::panels::library_problem::blocks_file_drops(&h.app)); + assert!(!crate::panels::notices::blocks_input(&h.app)); + drop_files(&mut h, &[b], false); + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the ordinary dialog survives B"); + assert!(h.app.scan.is_none() && h.app.import.is_none()); + assert_eq!(photos(&h), 0); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Close the open dialog")), "B is refused immediately: {:?}", h.app.ui.toast); + assert_eq!(h.app.dropped_files.len(), 1, "B cannot inherit A's protected provenance"); + h.app.ui.dialog = None; + finish_mixed_drop_batch(&mut h, &[a], direct); + } +} + +#[test] +fn a_new_drop_outside_the_barrier_is_refused_while_an_older_drop_waits_for_an_import() { + for direct in [false, true] { + let dir = Scratch::new("mixed-provenance-import"); + let mut h = app_with_library(&dir); + h.app.ui.settings.import_dropped_directly = direct; + let a = dir.path("trip/a.png"); + let b = dir.path("trip/b.png"); + show_launch_list(&mut h); + drop_files(&mut h, std::slice::from_ref(&a), false); + let stalled = crate::import::stall_scan(&mut h.app); + h.app.library_problem = None; + h.step(); + assert!(!crate::panels::library_problem::blocks_file_drops(&h.app)); + assert!(!crate::panels::notices::blocks_input(&h.app)); + drop_files(&mut h, &[b], false); + assert!(h.app.scan.is_some() && h.app.import.is_none(), "the original stalled import scan survives B"); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(photos(&h), 0); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("An import is running")), "B is refused immediately: {:?}", h.app.ui.toast); + assert_eq!(h.app.dropped_files.len(), 1, "B cannot inherit A's protected provenance"); + drop(stalled); // Channel-only fixture: no worker/process to stop. + assert!(h.step_until(T, |h| h.app.scan.is_none()), "the original stalled scan ends"); + finish_mixed_drop_batch(&mut h, &[a], direct); + } +} + +#[test] +fn both_drops_really_made_behind_the_barrier_remain_protected() { + for direct in [false, true] { + let dir = Scratch::new("mixed-provenance-both-protected"); + let mut h = app_with_library(&dir); + h.app.ui.settings.import_dropped_directly = direct; + let a = dir.path("trip/a.png"); + let b = dir.path("trip/b.png"); + show_launch_list(&mut h); + drop_files(&mut h, std::slice::from_ref(&a), false); + drop_files(&mut h, std::slice::from_ref(&b), false); + assert_eq!(h.app.dropped_files.len(), 2, "both arrivals really occurred behind the barrier"); + h.app.library_problem = None; + h.app.ui.dialog = Some(Dialog::WhatsNew); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew))); + assert_eq!(h.app.dropped_files.len(), 2, "both protected arrivals survive the later dialog"); + assert!(h.app.scan.is_none() && h.app.import.is_none()); + h.app.ui.dialog = None; + finish_mixed_drop_batch(&mut h, &[a, b], direct); + } +} + +/// Public take also serves web hosts: barrier reentry preserves order and consuming clears +/// protection, so a later ordinary drop cannot inherit the consumed batch's provenance. +#[test] +fn public_drop_take_preserves_reentered_barrier_order_and_clears_protection() { + let dir = Scratch::new("mixed-provenance-public-take"); + let mut h = app_with_library(&dir); + let a = dir.path("trip/a.png"); + let b = dir.path("trip/b.png"); + show_launch_list(&mut h); + drop_files(&mut h, std::slice::from_ref(&a), false); + assert_eq!(h.app.dropped_files.len(), 1); + h.app.library_problem = None; + h.app.ui.dialog = Some(Dialog::WhatsNew); + for _ in 0..3 { + h.step(); + } + assert_eq!(h.app.dropped_files.len(), 1, "A remains held behind the ordinary dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew))); + // Reenter before any pass without a blocker can consume A. + h.app.ui.dialog = None; + show_launch_list(&mut h); + drop_files(&mut h, std::slice::from_ref(&b), false); + assert_eq!(h.app.dropped_files.len(), 2); + assert_eq!(photos(&h), 0); + assert!(h.app.scan.is_none() && h.app.import.is_none()); + // Match the web ownership boundary without stepping a native consuming pass. + h.app.library_problem = None; + let taken = h.app.take_dropped_files(); + let paths = taken.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().into_owned()).collect::>(); + assert_eq!(paths, vec![a.clone(), b], "public take preserves arrival order through barrier reentry"); + assert!(h.app.take_dropped_files().is_empty(), "ownership transfers only once"); + h.app.ui.dialog = Some(Dialog::WhatsNew); + drop_files(&mut h, &[a], false); + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew))); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Close the open dialog")), "consumed protection cannot shelter a later drop"); + assert!(h.app.dropped_files.is_empty()); + h.app.ui.dialog = None; + for _ in 0..60 { + h.step(); + assert!(h.app.dropped_files.is_empty()); + assert!(!crate::import::running(&h.app)); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(photos(&h), 0, "neither taken handles nor refused arrivals replay"); + } +} diff --git a/crates/ui-egui/src/tests_edit_layout.rs b/crates/ui-egui/src/tests/edit_layout.rs similarity index 100% rename from crates/ui-egui/src/tests_edit_layout.rs rename to crates/ui-egui/src/tests/edit_layout.rs diff --git a/crates/ui-egui/src/tests_empty_library.rs b/crates/ui-egui/src/tests/empty_library.rs similarity index 100% rename from crates/ui-egui/src/tests_empty_library.rs rename to crates/ui-egui/src/tests/empty_library.rs diff --git a/crates/ui-egui/src/tests_error_kinds.rs b/crates/ui-egui/src/tests/error_kinds.rs similarity index 100% rename from crates/ui-egui/src/tests_error_kinds.rs rename to crates/ui-egui/src/tests/error_kinds.rs diff --git a/crates/ui-egui/src/tests_filmstrip.rs b/crates/ui-egui/src/tests/filmstrip.rs similarity index 83% rename from crates/ui-egui/src/tests_filmstrip.rs rename to crates/ui-egui/src/tests/filmstrip.rs index bf572c1d6..1529bcb41 100644 --- a/crates/ui-egui/src/tests_filmstrip.rs +++ b/crates/ui-egui/src/tests/filmstrip.rs @@ -31,30 +31,31 @@ fn filmstrip_shows_a_cjk_file_name() { #[test] fn filmstrip_stack_badge_expands_and_collapses_the_stack() { - let mut session = lightcraft_engine::Session::with_demo(); - let vis = session.visible_cloned(); + // Badge routing needs three real photos, not full 24 MP demo renders. + let library = crate::tests_fixtures::SyntheticLibrary::with_photos("filmstrip-stack-badge", 3); + let mut app = library.app(); + let vis = app.session.visible_cloned(); let (loose, top, member) = (vis[0], vis[1], vis[2]); let stack = Stack { id: StackId(1), photos: vec![top, member], collapsed: true }; - session.catalog.apply(Op::AddStack { stack }).unwrap(); - let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + app.session.catalog.apply(Op::AddStack { stack }).unwrap(); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); // the loose photo is the one in the loupe: the stack isn't selected let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [loose.0]}}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.set", json!({"view": "detail"}), T); assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); + assert!(h.settle(SETTLE), "the filmstrip settles before clicking its stack badge"); assert!(!h.app.session.visible_cloned().contains(&member), "collapsed: only the top shows"); let badge = format!("filmstack:{}", top.0); let r = h.request("ui.clickWidget", json!({"id": badge}), T); assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); + assert!(h.settle(SETTLE), "the expanded stack settles"); assert!(!h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a click on the badge expands the stack"); assert!(h.app.session.visible_cloned().contains(&member)); assert_eq!(h.app.session.selection.active, Some(loose), "the loupe keeps its photo"); let r = h.request("ui.clickWidget", json!({"id": badge}), T); assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); + assert!(h.settle(SETTLE), "the collapsed stack settles"); assert!(h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a second click collapses it"); assert!(!h.app.session.visible_cloned().contains(&member)); } diff --git a/crates/ui-egui/src/tests/fixtures.rs b/crates/ui-egui/src/tests/fixtures.rs new file mode 100644 index 000000000..0b07b543d --- /dev/null +++ b/crates/ui-egui/src/tests/fixtures.rs @@ -0,0 +1,86 @@ +//! Test-only fixtures: a small, real, file-backed photo library. +//! +//! The demo library generates 24 MP procedural scenes, which Nextest regenerates in every +//! process. The library/lifecycle tests only need an active detail photo whose file really +//! decodes, so this fixture writes 64×48 gradient PNGs and imports them as the only photos. + +use std::path::PathBuf; + +use lightcraft_catalog::{Op, Photo, Source}; + +use crate::{LightcraftApp, Services}; + +/// A temporary folder holding small PNGs; removes only its own folder when dropped. +/// +/// Keep this guard alive until after the [`Headless`](crate::headless::Headless) built from +/// [`SyntheticLibrary::app`] is dropped: the renderer reads the photo file from disk. +pub(crate) struct SyntheticLibrary { + dir: PathBuf, + photos: Vec, +} + +impl SyntheticLibrary { + /// A fresh folder for `tag`, with a deterministic 64×48 gradient `a.png`. + pub(crate) fn new(tag: &str) -> SyntheticLibrary { + SyntheticLibrary::with_photos(tag, 1) + } + + /// A fresh folder for `tag`, with `n` distinct 64×48 gradient PNGs (`a.png`, `b.png`, …). + pub(crate) fn with_photos(tag: &str, n: usize) -> SyntheticLibrary { + assert!((1..=26).contains(&n), "fixture photo names use a through z"); + let dir = std::env::temp_dir().join(format!("lc-ui-fixture-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (w, h) = (64usize, 48usize); + let mut photos = Vec::new(); + for i in 0..n { + let name = format!("{}.png", char::from(b'a' + i as u8)); + let data: Vec<[u8; 4]> = (0..w * h) + .map(|p| { + let (x, y) = (p % w, p / w); + [((x * 3 + i * 17) % 256) as u8, ((y * 4 + i * 9) % 256) as u8, ((x + y + i) % 256) as u8, 255] + }) + .collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let path = dir.join(name); + std::fs::write(&path, png).unwrap(); + photos.push(path); + } + SyntheticLibrary { dir, photos } + } + + /// A session whose library holds only the fixture photos, the first active and selected, with + /// the file system loaders installed so Detail really decodes the PNGs. + pub(crate) fn app(&self) -> LightcraftApp { + let mut session = lightcraft_engine::Session::new().with_fs(); + let mut ids = Vec::new(); + for (i, path) in self.photos.iter().enumerate() { + let id = session.catalog.alloc_photo_id(); + // distinct capture times keep the default (newest first) view order deterministic + let captured = format!("2026-01-01T10:{:02}:00", i % 60); + let mut photo = Photo::new( + id, + Source::File { path: path.to_string_lossy().into_owned() }, + path.file_name().map_or("photo.png", |n| n.to_str().unwrap_or("photo.png")), + "PNG", + 64, + 48, + &captured, + ); + photo.captured = Some(captured); + session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + ids.push(id); + } + if let Some(first) = ids.first() { + session.selection = lightcraft_engine::Selection::single(*first); + } + LightcraftApp::new(session, Services { png: None, ..Default::default() }) + } +} + +impl Drop for SyntheticLibrary { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests/focus_point.rs similarity index 100% rename from crates/ui-egui/src/tests_focus_point.rs rename to crates/ui-egui/src/tests/focus_point.rs diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests/folder_drop.rs similarity index 99% rename from crates/ui-egui/src/tests_folder_drop.rs rename to crates/ui-egui/src/tests/folder_drop.rs index 1b6536242..b7a515131 100644 --- a/crates/ui-egui/src/tests_folder_drop.rs +++ b/crates/ui-egui/src/tests/folder_drop.rs @@ -95,7 +95,7 @@ fn release(h: &mut Headless, at: Pos2) { /// What the dragged label says this frame instead of the count, if a drop target set it. fn hint(h: &Headless) -> Option { - h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text) + h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("drag-photos-hint"))).map(|(_, text)| text) } /// Click a widget the way a person does (press and release on it). diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests/grading.rs similarity index 100% rename from crates/ui-egui/src/tests_grading.rs rename to crates/ui-egui/src/tests/grading.rs diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests/grid.rs similarity index 100% rename from crates/ui-egui/src/tests_grid.rs rename to crates/ui-egui/src/tests/grid.rs diff --git a/crates/ui-egui/src/tests_guided_guides.rs b/crates/ui-egui/src/tests/guided_guides.rs similarity index 100% rename from crates/ui-egui/src/tests_guided_guides.rs rename to crates/ui-egui/src/tests/guided_guides.rs diff --git a/crates/ui-egui/src/tests_histogram_drag.rs b/crates/ui-egui/src/tests/histogram_drag.rs similarity index 100% rename from crates/ui-egui/src/tests_histogram_drag.rs rename to crates/ui-egui/src/tests/histogram_drag.rs diff --git a/crates/ui-egui/src/tests_info_dialogs.rs b/crates/ui-egui/src/tests/info_dialogs.rs similarity index 100% rename from crates/ui-egui/src/tests_info_dialogs.rs rename to crates/ui-egui/src/tests/info_dialogs.rs diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests/keymap.rs similarity index 87% rename from crates/ui-egui/src/tests_keymap.rs rename to crates/ui-egui/src/tests/keymap.rs index b48da7cdf..6e784de1a 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests/keymap.rs @@ -103,6 +103,27 @@ fn a_key_given_to_a_command_overrides_a_fixed_key() { assert_eq!(before, after, "the rating key must not fire too"); } +/// A declared key that yields to another command's saved key can be taken back with ↺. +#[test] +fn restore_takes_back_a_declared_key_another_command_holds() { + use crate::shortcuts::shortcut_of; + let mut h = demo(); + h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into()); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + click(&mut h, "check:shortcuts.showAll"); + click(&mut h, "field:shortcutsSearch"); + assert_eq!(h.request("ui.text", json!({"text": "flag as pick"}), T)["ok"], true); + h.step(); + h.step(); + click(&mut h, "button:shortcutReset-photo.pick"); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P")); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.increaseRating"), None); + assert!(h.app.ui.settings.keymap.is_empty(), "{:?}", h.app.ui.settings.keymap); +} + /// Open the editor, filtered to `search`, and start recording for `id`. fn record(h: &mut Headless, search: &str, id: &str) { if h.app.ui.dialog.is_none() { @@ -176,6 +197,51 @@ fn shift_insert_pastes_plainly() { assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings"); assert_eq!(h.app.session.undo.len(), undo + 1, "the settings were pasted"); } + +/// ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V) copy and paste metadata, as in Lightroom Classic's Library, +/// both as key presses and as the clipboard events Windows and Linux turn them into; the edit +/// settings clipboard is left alone. +#[test] +fn cmd_alt_shift_c_and_v_copy_and_paste_metadata() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.visible().iter().copied().take(2).collect(); + let (a, b) = (ids[0], ids[1]); + let title = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.title.clone(); + h.app.run("library.select", json!({"ids": [a.0]})).unwrap(); + h.app.run("photo.setMeta", json!({"title": "Dawn", "creator": "Me"})).unwrap(); + h.app.run("library.select", json!({"ids": [b.0]})).unwrap(); + h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap(); + let chord = json!({"cmd": true, "alt": true, "shift": true}); + let press = |h: &mut Headless, k: &str| { + let mut p = chord.clone(); + p["key"] = json!(k); + let r = h.request("ui.key", p, T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + }; + + h.app.run("library.select", json!({"ids": [a.0]})).unwrap(); + press(&mut h, "C"); + assert_eq!(h.app.session.meta_clipboard.as_ref().map(|m| m["title"].clone()), Some(json!("Dawn")), "the key copied the metadata"); + h.app.run("library.select", json!({"ids": [b.0]})).unwrap(); + press(&mut h, "V"); + assert_eq!(title(&h, b), "Dawn", "the key pasted the metadata"); + assert_eq!(h.app.session.catalog.photo(b).unwrap().meta.creator, "Me"); + + h.app.session.meta_clipboard = None; + h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap(); + let all = egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT; + h.app.run("library.select", json!({"ids": [a.0]})).unwrap(); + h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Copy]); + h.settle(SETTLE); + assert!(h.app.session.meta_clipboard.is_some(), "the Copy event with ⌥⇧ held copied the metadata"); + h.app.run("library.select", json!({"ids": [b.0]})).unwrap(); + h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Paste("text".into())]); + h.settle(SETTLE); + assert_eq!(title(&h, b), "Dawn", "the Paste event with ⌥⇧ held pasted the metadata"); + assert!(h.app.session.clipboard.is_none(), "no edit settings were copied"); + assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings"); +} /// The opening release can carry both physical shortcut keys and the replacement text. #[test] fn opening_numeric_release_keeps_keys_in_the_editor() { diff --git a/crates/ui-egui/src/tests_keyword_files.rs b/crates/ui-egui/src/tests/keyword_files.rs similarity index 76% rename from crates/ui-egui/src/tests_keyword_files.rs rename to crates/ui-egui/src/tests/keyword_files.rs index f7180c317..1d2f66340 100644 --- a/crates/ui-egui/src/tests_keyword_files.rs +++ b/crates/ui-egui/src/tests/keyword_files.rs @@ -6,15 +6,14 @@ use std::time::Duration; use serde_json::json; use crate::headless::Headless; -use crate::{LightcraftApp, Services}; +use crate::tests_fixtures::SyntheticLibrary; const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); -fn app() -> Headless { - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); - let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - h.settle(SETTLE); +fn app(library: &SyntheticLibrary) -> Headless { + let mut h = Headless::new(library.app(), [1200.0, 800.0], 1.0); + assert!(h.settle(SETTLE), "keyword fixture did not settle"); h } @@ -26,9 +25,9 @@ fn temp_dir(tag: &str) -> std::path::PathBuf { } fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { - let r = h.request("engine.execute", json!({"command": command, "params": params}), T); - h.settle(SETTLE); - r + // These commands finish synchronously, including their toast, before replying. Settling + // unrelated photo renders afterwards advances the simulated clock and can expire it. + h.request("engine.execute", json!({"command": command, "params": params}), T) } fn toast(h: &Headless) -> String { @@ -38,7 +37,8 @@ fn toast(h: &Headless) -> String { /// The File menu offers Import Keywords… and Export Keywords…. #[test] fn the_file_menu_offers_keyword_files() { - let mut h = app(); + let library = SyntheticLibrary::new("keyword-files-menu"); + let mut h = app(&library); let r = h.request("ui.menu.list", json!({}), T); let items = r["result"].to_string(); for id in ["file.importKeywords", "file.exportKeywords"] { @@ -50,7 +50,8 @@ fn the_file_menu_offers_keyword_files() { #[test] fn importing_keywords_says_what_it_added() { let dir = temp_dir("import"); - let mut h = app(); + let library = SyntheticLibrary::new("keyword-files-import"); + let mut h = app(&library); let path = dir.join("vocabulary.utf8"); std::fs::write(&path, "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n").unwrap(); let r = run(&mut h, "file.importKeywords", json!({"path": path.to_string_lossy()})); @@ -64,7 +65,8 @@ fn importing_keywords_says_what_it_added() { #[test] fn exporting_keywords_warns_about_capture_one() { let dir = temp_dir("export"); - let mut h = app(); + let library = SyntheticLibrary::new("keyword-files-export"); + let mut h = app(&library); let id = h.app.session.visible_cloned()[0].0; run(&mut h, "photo.setMeta", json!({"ids": [id], "addKeywords": ["fish, chips"]})); let path = dir.join("keywords.txt"); @@ -79,7 +81,8 @@ fn exporting_keywords_warns_about_capture_one() { #[test] fn exporting_keywords_names_what_it_left_out() { let dir = temp_dir("export-left-out"); - let mut h = app(); + let library = SyntheticLibrary::new("keyword-files-export-left-out"); + let mut h = app(&library); let id = h.app.session.visible_cloned()[0].0; run(&mut h, "photo.setMeta", json!({"ids": [id], "addKeywords": ["Travel|[draft]"]})); let path = dir.join("keywords.txt"); @@ -96,9 +99,12 @@ fn exporting_keywords_names_what_it_left_out() { /// A list that can't be written (a folder that isn't there) says why, as importing one does. #[test] fn exporting_keywords_says_why_it_failed() { - let mut h = app(); - let path = std::env::temp_dir().join("lc-no-such-folder-for-keywords").join("keywords.txt"); + let library = SyntheticLibrary::new("keyword-files-export-failure"); + let mut h = app(&library); + let dir = temp_dir("export-failure"); + let path = dir.join("missing-folder").join("keywords.txt"); let r = run(&mut h, "file.exportKeywords", json!({"path": path.to_string_lossy()})); assert_eq!(r["ok"], false, "{r}"); assert!(toast(&h).contains("keywords.txt"), "{}", toast(&h)); + let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests/keyword_list.rs similarity index 97% rename from crates/ui-egui/src/tests_keyword_list.rs rename to crates/ui-egui/src/tests/keyword_list.rs index bdeaef0dc..579d79867 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests/keyword_list.rs @@ -456,8 +456,8 @@ fn ticking_a_keyword_keeps_the_list_in_place() { assert!(box_moved, "the box changed height (the list was held, not left alone)"); } -/// At the top of the panel the box is in sight: a ticked keyword's chip pushes the list down, -/// as before. +/// At the top of the panel the box is in sight: adding keyword chips pushes the list down +/// without scrolling the panel. Checkbox interaction is covered separately above. #[test] fn at_the_top_the_box_grows_in_sight() { let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); @@ -465,7 +465,10 @@ fn at_the_top_the_box_grows_in_sight() { let (title, box_top) = (rect(&h, "keywordList:topLevel"), rect(&h, "field:keyword")); for k in ["aaa1", "aaa2", "aaa3", "aaa4", "aaa5", "aaa6"] { run(&mut h, "keyword.create", json!({"name": k})); - ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + // Growing chips eventually push later rows outside this short viewport. Add through + // the command so this layout regression never relies on clicking a clipped checkbox. + run(&mut h, "photo.setMeta", json!({"ids": ids, "addKeywords": [k]})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|keyword| keyword == k)), "{k} was added to both photos"); } assert_eq!(rect(&h, "field:keyword"), box_top, "the panel stays at its top"); assert!(rect(&h, "keywordList:topLevel").top() > title.top(), "the chips pushed the list down"); diff --git a/crates/ui-egui/src/tests_keyword_set.rs b/crates/ui-egui/src/tests/keyword_set.rs similarity index 87% rename from crates/ui-egui/src/tests_keyword_set.rs rename to crates/ui-egui/src/tests/keyword_set.rs index 4667f5367..4874a58a9 100644 --- a/crates/ui-egui/src/tests_keyword_set.rs +++ b/crates/ui-egui/src/tests/keyword_set.rs @@ -160,3 +160,20 @@ fn save_as_a_new_set_keeps_the_original() { assert_eq!(names, ["Recent Keywords", "Weddings", "Ceremonies"]); assert_eq!(s["current"], "Ceremonies"); } + +/// ⌥0 / ⌥⇧0 (Alt+0 / Alt+Shift+0) go to the next and the previous keyword set, as in Lightroom +/// Classic's Library, and ⌥1 then applies the new set's first keyword. +#[test] +fn alt_0_and_alt_shift_0_cycle_the_keyword_sets() { + let mut h = keywords_panel(); + h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + h.app.session.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + h.settle(SETTLE); + ask(&mut h, "ui.key", json!({"key": "0", "alt": true})); + assert_eq!(sets(&mut h)["current"], "Weddings", "⌥0: the next set"); + ask(&mut h, "ui.key", json!({"key": "1", "alt": true})); + let id = h.app.session.selected()[0]; + assert!(h.app.session.catalog.photo(id).unwrap().meta.keywords.iter().any(|k| k == "ceremony"), "⌥1 applied the set's keyword"); + ask(&mut h, "ui.key", json!({"key": "0", "alt": true, "shift": true})); + assert_eq!(sets(&mut h)["current"], "Recent Keywords", "⌥⇧0: the previous set"); +} diff --git a/crates/ui-egui/src/tests_keyword_sidebar.rs b/crates/ui-egui/src/tests/keyword_sidebar.rs similarity index 100% rename from crates/ui-egui/src/tests_keyword_sidebar.rs rename to crates/ui-egui/src/tests/keyword_sidebar.rs diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests/keywording.rs similarity index 93% rename from crates/ui-egui/src/tests_keywording.rs rename to crates/ui-egui/src/tests/keywording.rs index 076f25789..754a5abab 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests/keywording.rs @@ -108,6 +108,55 @@ fn the_add_keyword_box_pastes_adds_and_gives_up() { assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"harbour".to_string())), "Esc gave it up"); } +/// ⌘K (Ctrl+K) is Add Keywords: it shows the Keywords panel, opens Keywording when it is folded +/// shut, and puts the cursor in the Add keyword box, so what is typed next goes to the selection. +#[test] +fn cmd_k_puts_the_cursor_in_the_add_keyword_box() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywording"})); + ask(&mut h, "ui.set", json!({"right": "none"})); + ask(&mut h, "ui.key", json!({"key": "K", "cmd": true})); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Keywords, "the Keywords panel"); + assert!(!h.app.ui.section_folded("keywording") && has(&h, "field:keyword"), "Keywording is open"); + assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box has the cursor"); + ask(&mut h, "ui.text", json!({"text": "harbour"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"harbour".to_string())), "typed and given to both"); + assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "Return gave up the cursor"); + ask(&mut h, "ui.key", json!({"key": "K", "cmd": true})); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Keywords, "pressed again: the panel stays"); + assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "and the cursor is back in the box"); +} + +/// With no photo, ⌘K shows the Keywords panel and its note; selecting photos afterwards doesn't put the +/// cursor in the Add keyword box. +#[test] +fn cmd_k_without_a_photo_leaves_the_cursor_alone() { + let (mut h, ids) = two_selected(); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": []}})); + assert!(h.app.session.active().is_none(), "no active photo"); + ask(&mut h, "ui.set", json!({"right": "none"})); + ask(&mut h, "ui.key", json!({"key": "K", "cmd": true})); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Keywords, "the Keywords panel"); + assert!(!has(&h, "field:keyword"), "no box without a photo"); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": ids}})); + assert!(has(&h, "field:keyword")); + assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box waits for a click"); +} + +/// An Add Keywords request the box wasn't drawn for in time (the panel couldn't show) expires: a +/// later draw of the box leaves the cursor alone. +#[test] +fn a_stale_add_keywords_request_leaves_the_cursor_alone() { + let (mut h, _) = two_selected(); + ask(&mut h, "ui.set", json!({"right": "keywords"})); + assert!(has(&h, "field:keyword")); + h.app.ui.focus_keywords = Some(h.view.ctx.cumulative_pass_nr().saturating_sub(crate::state::FOCUS_KEYWORDS_PASSES + 1)); + h.step(); + assert_eq!(h.app.ui.focus_keywords, None, "the request is used up"); + assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box waits for a click"); +} + /// The keyword set's buttons say whether the selection has each keyword, as their ⌥1–⌥9 toggle it /// on the selection: on when every selected photo has it, partly on when only some do. #[test] diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests/labels.rs similarity index 100% rename from crates/ui-egui/src/tests_labels.rs rename to crates/ui-egui/src/tests/labels.rs diff --git a/crates/ui-egui/src/tests_launch_library.rs b/crates/ui-egui/src/tests/launch_library.rs similarity index 96% rename from crates/ui-egui/src/tests_launch_library.rs rename to crates/ui-egui/src/tests/launch_library.rs index b0b3bec6b..448bb735a 100644 --- a/crates/ui-egui/src/tests_launch_library.rs +++ b/crates/ui-egui/src/tests/launch_library.rs @@ -129,8 +129,8 @@ fn the_list_checks_each_library_and_selects_the_last_one() { let _ = std::fs::remove_dir_all(&dir); } -/// Every file under `dir`: relative path → (contents, modification time). -fn snapshot(dir: &Path) -> std::collections::BTreeMap, std::time::SystemTime)> { +/// Every file under `dir`: relative path → (contents, length, modification time). +fn snapshot(dir: &Path) -> std::collections::BTreeMap>, u64, std::time::SystemTime)> { let mut out = std::collections::BTreeMap::new(); let mut todo = vec![dir.to_path_buf()]; while let Some(d) = todo.pop() { @@ -140,13 +140,25 @@ fn snapshot(dir: &Path) -> std::collections::BTreeMap, std::ti if m.is_dir() { todo.push(p.clone()); } - let bytes = if m.is_file() { std::fs::read(&p).unwrap() } else { Vec::new() }; - out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, m.modified().unwrap())); + let (bytes, len) = if m.is_file() { (contents(&p), m.len()) } else { (Some(Vec::new()), 0) }; + out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, len, m.modified().unwrap())); } } out } +/// The bytes of the file `p`, or `None` for a lock file that a [`lightcraft_catalog::LibraryLock`] +/// holds on Windows: there `LockFileEx` locks the file's bytes, and reading them through another +/// handle fails with os error 33 (`ERROR_LOCK_VIOLATION`). Such a file is compared by its length +/// and modification time only. +fn contents(p: &Path) -> Option> { + match std::fs::read(p) { + Ok(bytes) => Some(bytes), + Err(e) if cfg!(windows) && e.raw_os_error() == Some(33) && p.file_name().is_some_and(|n| n == lightcraft_catalog::lock::LOCK) => None, + Err(e) => panic!("{}: {e}", p.display()), + } +} + /// Listing the recent libraries (and drawing the window) writes nothing into any of them: no /// lock file, no owner file, nothing modified — free, held by another program or not a library. #[test] @@ -956,8 +968,19 @@ fn recent_libraries_are_checked_again_only_when_the_window_comes_back() { /// (FredApps on #2689, #2774). #[test] fn a_library_found_again_comes_back_to_the_launch_list() { + found_again(false); +} + +/// The same when every frame is laid out twice (`request_discard`): an answer applied in a frame is +/// visible through all of its passes, and scheduling does not ask about it again in that frame. +#[test] +fn a_library_found_again_comes_back_to_the_launch_list_across_a_multipass_frame() { + found_again(true); +} + +fn found_again(multipass: bool) { use std::sync::atomic::{AtomicBool, Ordering}; - let dir = temp("found-again"); + let dir = temp(if multipass { "found-again-multipass" } else { "found-again" }); let a = make_library(&dir.join("A")); let plugged = std::sync::Arc::new(AtomicBool::new(false)); let p = plugged.clone(); @@ -969,6 +992,21 @@ fn a_library_found_again_comes_back_to_the_launch_list() { app.ui.settings.recent_libraries = vec![a.clone()]; app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + if multipass { + // The existing context hook egui calls at the start of every pass: ask for the second layout + // pass on every pass 0 (which egui otherwise does only for e.g. a Grid sizing change). + h.view.ctx.on_begin_pass( + "test: force a second layout pass", + std::sync::Arc::new(|ui: &mut egui::Ui| { + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("test: force a second layout pass"); + } + }), + ); + let passes = h.view.ctx.cumulative_pass_nr(); + h.step(); + assert_eq!(h.view.ctx.cumulative_pass_nr() - passes, 2, "the hook forced the second pass"); + } let row = |h: &Headless| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).map(|c| c.libraries[0].clone()).unwrap(); assert!( h.step_until(T, |h| { diff --git a/crates/ui-egui/src/tests_layout_image.rs b/crates/ui-egui/src/tests/layout_image.rs similarity index 100% rename from crates/ui-egui/src/tests_layout_image.rs rename to crates/ui-egui/src/tests/layout_image.rs diff --git a/crates/ui-egui/src/tests/library_problem.rs b/crates/ui-egui/src/tests/library_problem.rs new file mode 100644 index 000000000..1a2e5a2d9 --- /dev/null +++ b/crates/ui-egui/src/tests/library_problem.rs @@ -0,0 +1,806 @@ +//! Issue #100: a library that can't be opened at launch is never replaced by a silent in-memory +//! demo session. The window says why and offers Try Again / Choose Another Library… / Continue +//! Without Saving / Quit; a temporary session shows a banner and never writes to the library. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::library_problem::LibraryProblem; +use crate::tests_fixtures::SyntheticLibrary; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +/// A settings notice owns the frame: the launch list / library-error window is another +/// `Foreground` area and must not be drawn above the prompt or consume its keys. +#[test] +fn a_notice_prompt_hides_the_library_problem_window_and_takes_its_keys() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + h.step(); + h.step(); + assert!(has(&h, "button:libraryRetry"), "the library problem window is shown without a prompt"); + h.app.notices.push("The settings file could not be read and was reset.".into()); + h.step(); + h.step(); + assert!(has(&h, "button:noticeOk"), "the settings notice is laid out"); + assert!(!has(&h, "button:libraryRetry"), "the library problem window is not drawn above the prompt"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.notices.is_empty(), "Escape safely dismissed the notice"); + assert!(!h.app.ui.quit, "the prompt's Escape did not reach the library window"); + assert!(has(&h, "button:libraryRetry"), "the library problem window returns after the prompt"); +} + +/// The launch list's own arrow/Return/Escape handling must stay still while a settings notice is +/// up. Use a real chooser: with the old error window this assertion could never reach +/// `chooser_keys` at all. +#[test] +fn a_notice_prompt_over_the_launch_list_takes_escape_without_quitting_it() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.recent_libraries = vec!["/no/such/library-one".into(), "/no/such/library-two".into()]; + app.ui.settings.library_path = "/no/such/library-one".into(); + app.library_problem = Some(LibraryProblem::choose(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:libraryOpen"), "the launch list is shown without a prompt"); + let selected = h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).map(|c| c.selected); + h.app.notices.push("The settings file could not be read and was reset.".into()); + h.step(); + h.step(); + assert!(has(&h, "button:noticeOk"), "the settings notice is laid out"); + assert!(!has(&h, "button:libraryOpen"), "the launch list is not drawn above the prompt"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.notices.is_empty(), "Escape safely dismissed the notice"); + assert!(!h.quit_requested(), "the prompt's Escape did not reach the launch list"); + assert!(h.app.library_problem.is_some(), "the launch list stays"); + assert_eq!( + h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).map(|c| c.selected), + selected, + "the prompt's Escape did not move the launch-list selection" + ); + assert!(has(&h, "button:libraryOpen"), "the launch list returns after the prompt"); +} + +#[test] +fn unopenable_library_asks_instead_of_running_a_demo() { + let dir = std::env::temp_dir().join(format!("lc-ui-libproblem-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let lib = dir.join("lib"); + std::fs::create_dir_all(&lib).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let photo = dir.join("a.png"); + std::fs::write(&photo, png).unwrap(); + + // another program has the library open: the launch fails like the desktop host's would + let held = lightcraft_catalog::LibraryLock::acquire(&lib, "another LightCraft").unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + let err = session.open_library(&lib, true).unwrap_err().to_string(); + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + let mut app = LightcraftApp::new( + session, + Services { + png: Some(Box::new(png)), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }, + ); + app.library_problem = + Some(LibraryProblem { pending_import: vec![photo.to_string_lossy().to_string()], ..LibraryProblem::new(lib.to_string_lossy(), err) }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(h.app.session.catalog.is_empty(), "no demo photos"); + for b in ["button:libraryRetry", "button:libraryChoose", "button:libraryTemporary", "button:libraryQuit"] { + assert!(has(&h, b), "{b}"); + } + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("dialog.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + let inspect = h.request("ui.inspect", json!({}), T); + let p = &inspect["result"]["libraryProblem"]; + assert!(p["error"].as_str().unwrap().contains("already open in another LightCraft"), "{p}"); + assert_eq!(p["temporarySession"], false); + + // still locked: Try Again keeps the window, with the error + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + h.step(); + assert!(h.app.session.library.is_none()); + assert!(has(&h, "button:libraryRetry")); + + // Continue Without Saving: a banner, the command-line photo in the temporary session, and + // nothing written into the library folder + let before: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryTemporary"}), T)["ok"], true); + h.step(); + h.step(); + assert!(!has(&h, "button:libraryRetry")); + assert!(has(&h, "indicator:temporarySession")); + assert!(h.app.session.library.is_none()); + // imported on the import worker (issue #374: never synchronously on the UI thread) + h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1); + assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the temporary session"); + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("banner.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 3}}), T); + let after: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(before, after, "the temporary session never writes to the library"); + + // the other program quits; Open Library… → Try Again opens it, the banner goes away + drop(held); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryReopen"}), T)["ok"], true); + h.step(); + assert!(has(&h, "button:libraryRetry")); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + h.step(); + h.step(); + assert!(h.app.session.library.is_some()); + assert!(h.app.library_problem.is_none()); + assert!(!has(&h, "indicator:temporarySession") && !has(&h, "button:libraryRetry")); + assert!(h.app.session.catalog.is_empty(), "an opened (new) library is never seeded with demo photos here"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Pressing the dim raises its egui area. The blocking window must be moved back above it, or its +/// own buttons are covered: the click then reports a no-op success and the state never changes. +#[test] +fn a_backdrop_press_keeps_the_library_window_above_its_dim() { + let files = SyntheticLibrary::new("library-backdrop-press"); + let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0); + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + assert!(h.settle(Duration::from_secs(20)), "the library window settles (pending slots: {:?})", h.app.renderer.pending_slots()); + assert!(has(&h, "button:libraryTemporary"), "the window is laid out"); + // Outside the centred window: on the full-screen dim, which egui raises when pressed. + let at = egui::pos2(10.0, 10.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(at), press(true), press(false)]); + h.step(); + h.step(); + assert!(h.app.library_problem.as_ref().is_some_and(|p| !p.dismissed), "the backdrop itself chose nothing"); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryTemporary"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!( + h.app.library_problem.as_ref().is_some_and(|p| p.dismissed), + "the window's own button made the actual state change after the backdrop press" + ); + assert!(!has(&h, "button:libraryRetry"), "Continue Without Saving removed the blocking window"); +} + +/// The library window is another foreground modal over Settings: its raw Escape and outside-click +/// handlers must stay still while it is on screen, then the dialog keeps its ordinary behaviour. +#[test] +fn the_library_window_blocks_settings_escape_and_the_backdrop() { + let files = SyntheticLibrary::new("library-blocks-settings"); + let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0); + h.step(); + assert!(h.app.run("app.settings", json!({})).is_ok(), "Settings opens"); + assert!(h.settle(Duration::from_secs(20)), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots()); + h.app.ui.settings.close_dialogs_outside = true; + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + assert!(h.settle(Duration::from_secs(20)), "the library window settles (pending slots: {:?})", h.app.renderer.pending_slots()); + assert!(has(&h, "button:libraryRetry"), "the library window is laid out"); + // Escape belongs to the window, not to Settings behind it (the window has no Escape action + // for this error; the launch list handles its own keys separately). + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Escape closed Settings behind the library window"); + assert!(h.app.library_problem.is_some(), "the library window stayed"); + // The outside-click preference reads raw pointer events without asking the layer, so it must + // be gated too. The dim swallows the click itself. + let at = egui::pos2(10.0, 890.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(at), press(true), press(false)]); + h.step(); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "the backdrop click closed Settings behind the library window"); + // Positive control: with the library window gone, Settings' own Escape works again. + h.app.library_problem = None; + h.step(); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "Escape closes Settings without the library window"); +} + +/// A dialog opened before the launch list (Settings) is not the list's own New Library prompt: +/// the list stays visible above it and keeps owning Escape, instead of vanishing and letting the +/// unrelated dialog's raw handlers run. +#[test] +fn an_unrelated_open_dialog_does_not_own_the_launch_list_or_its_barrier() { + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + let mut h = Headless::new( + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), + [1200.0, 900.0], + 1.0, + ); + h.settle(T); + assert!(h.app.run("app.settings", json!({})).is_ok(), "Settings opens first"); + h.step(); + let mut problem = LibraryProblem::choose(&settings); + if let Some(c) = problem.chooser.as_mut() { + c.over = Some(h.app.session.library_generation()); + } + h.app.library_problem = Some(problem); + h.step(); + h.step(); + assert!(has(&h, "button:libraryOpen"), "the launch list stays above the unrelated Settings dialog"); + assert!(crate::panels::library_problem::blocks_dialog_input(&h.app), "the launch list owns input over an unrelated dialog"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.library_problem.is_none(), "the launch list's own Escape kept the open library"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Escape did not close Settings behind the launch list"); +} + +/// The launch list's own Escape is not a Settings raw handler: with no notice prompt blocking it, +/// it still quits from an empty list (the library chooser's long-standing behaviour). +#[test] +fn the_launch_list_still_quits_on_escape_without_a_notice() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.recent_libraries = vec!["/no/such/library-one".into(), "/no/such/library-two".into()]; + app.ui.settings.library_path = "/no/such/library-one".into(); + app.library_problem = Some(LibraryProblem::choose(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:libraryOpen"), "the launch list is shown without a prompt"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.quit_requested(), "the launch list's own Escape still quits when no notice blocks it"); +} + +/// A blocking library window created after `raw_input_hook` must not let the background text +/// field swallow the same frame's host text before the new modal area has settled. The same +/// sequence without the window is the positive control. +fn creation_frame_drops_background_text(problem: LibraryProblem, button: &str) { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 760.0], 1.0); + h.settle(T); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "the search field takes focus: {r}"); + let r = h.request("ui.text", json!({"text": "base"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.search, "base"); + + h.events.push(egui::Event::Text("leak".into())); + let mut problem = Some(problem); + h.set_after_raw_input_hook(move |app| app.library_problem = problem.take()); + h.step(); + assert_eq!(h.app.ui.search, "base", "the new library window's creation frame dropped the background text"); + assert!(!h.view.ctx.text_edit_focused(), "the background text field's focus was surrendered"); + h.step(); + assert!(has(&h, button), "the blocking window is laid out after its shield pass"); + + // Positive control: without the window, the same focused field receives text in one frame. + h.app.library_problem = None; + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.events.push(egui::Event::Text("after".into())); + h.step(); + assert_eq!(h.app.ui.search, "baseafter", "ordinary background text input still works"); +} + +#[test] +fn library_error_and_chooser_creation_frames_drop_background_text() { + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + creation_frame_drops_background_text(LibraryProblem::new("/no/such/library", "a test error"), "button:libraryRetry"); + creation_frame_drops_background_text(LibraryProblem::choose(&settings), "button:libraryOpen"); +} + +/// The library barrier removes events that `raw_input_hook` already injected. Like the prompt +/// barrier, it must fail the owning gesture; otherwise `ui.text`, `ui.hoverWidget` and synthetic +/// paste can report `ok: true` for input no panel ever saw. The window is created after the hook, +/// exactly as `alt_at_launch` or a command drawn late in the frame can do. +fn creation_after_raw_input_cancels_injected_text(problem: LibraryProblem) { + let mut h = Headless::new( + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + let (tx, rx) = std::sync::mpsc::channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.text".into(), json!({"text": "leak"}), tx).expect("the text gesture is admitted"); + h.step(); // resolve: one Text event is queued + assert_eq!(h.app.synthetic.len(), 1, "the text gesture is ready to inject"); + let mut problem = Some(problem); + h.set_after_raw_input_hook(move |app| app.library_problem = problem.take()); + h.step(); // raw hook injects; the hook creates the window; the barrier drops the batch + let reply = rx.try_recv().expect("the dropped gesture answers"); + assert_eq!(reply["ok"], false, "the library barrier must not report a false success: {reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("blocking library window"), "{reply}"); + assert!(h.app.synthetic.is_empty(), "the dropped text batch left nothing queued"); + assert!(h.app.pending_gestures.is_empty(), "the dropped gesture was canceled, not kept"); +} + +#[test] +fn library_error_and_chooser_creation_after_raw_input_cancel_an_injected_gesture() { + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + creation_after_raw_input_cancels_injected_text(LibraryProblem::new("/no/such/library", "a test error")); + creation_after_raw_input_cancels_injected_text(LibraryProblem::choose(&settings)); +} + +/// `alt_at_launch` creates the chooser from `logic`, after `raw_input_hook` injected this frame's +/// synthetic batch. That later creation path must cancel the dropped gesture too. +#[test] +fn alt_launch_creation_after_raw_input_cancels_an_injected_gesture() { + let mut h = Headless::new( + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + let (tx, rx) = std::sync::mpsc::channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.text".into(), json!({"text": "leak"}), tx).expect("the text gesture is admitted"); + h.step(); + assert_eq!(h.app.synthetic.len(), 1); + h.app.alt_asks_for_library = true; + h.time = 0.1; // stay inside the Alt-at-launch watch window regardless of settle time + h.events.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + h.step(); // raw hook injects Text, then alt_at_launch creates the chooser and its barrier drops it + assert!(h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some(), "Alt at launch opened the chooser"); + let reply = rx.try_recv().expect("the dropped gesture answers"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("blocking library window"), "{reply}"); + assert!(h.app.synthetic.is_empty() && h.app.pending_gestures.is_empty()); +} + +/// A held host press over a background control in the same frame as a library window created +/// after `raw_input_hook` is released by the window's first pass; its later release must not +/// become a background click. +#[test] +fn a_held_background_press_is_released_when_a_library_window_appears_after_raw_input() { + let files = SyntheticLibrary::new("library-after-raw-press"); + let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0); + h.step(); + assert!(h.app.run("app.settings", json!({})).is_ok(), "Settings opens"); + assert!(h.settle(T), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "check:settings.confirmDelete"; + let at = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| rect.center()).expect("the checkbox is on screen"); + let before = h.app.ui.settings.confirm_delete; + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(at), press(true)]); + h.set_after_raw_input_hook(|app| app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error"))); + h.step(); + assert!(h.app.library_problem.is_some(), "the library window is shown"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the creation pass released the held press"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the press did not reach Settings"); + h.events.push(press(false)); + h.step(); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the release did not become a background click"); + assert!(h.app.library_problem.is_some(), "the release did not activate the background control"); +} + +/// A library window first seen after `raw_input_hook` drops wheel, key and background pointer +/// input already accepted for the pass. Observed on egui's actual input state, not on the event +/// list the barrier edits. +#[test] +fn a_library_window_first_seen_after_begin_pass_drops_background_input() { + let mut h = + Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), [800.0, 600.0], 1.0); + h.settle(T); + let _ = pass_log(&mut h); + let at = egui::pos2(30.0, 40.0); + h.events.extend([ + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 2.0), + phase: egui::TouchPhase::Start, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + }, + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::ModifiersChanged(egui::Modifiers::NONE), + ]); + let mut problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + h.set_after_raw_input_hook(move |app| app.library_problem = problem.take()); + h.step(); + assert!(h.app.library_problem.is_some(), "the window was created after this pass's raw input"); + let (down, keys, scroll) = h.view.ctx.input(|i| (i.pointer.any_down(), i.keys_down.clone(), i.smooth_scroll_delta)); + assert!(!down, "the held background pointer press was released"); + assert!(keys.is_empty(), "the background key press was dropped: {keys:?}"); + assert_eq!(scroll, egui::Vec2::ZERO, "wheel scrolling was cleared"); + assert!(!h.view.ctx.input(|i| i.key_pressed(egui::Key::A)), "the dropped key press is not reported to panels"); + // The creation frame is a dedicated shield pass that egui discards, then the background pass. + assert_eq!( + pass_log(&mut h), + vec![crate::panels::library_problem::LibraryPass::Shield, crate::panels::library_problem::LibraryPass::Background], + "the creation frame draws only the window and is discarded before the background pass" + ); +} + +/// A Touch Start in one pass and its End/Cancel in a pass where a library window appears after +/// `raw_input_hook` must leave egui's own touch state empty: `begin_pass` processes the release +/// before the barrier filters the events. Asserting that the End/Cancel events merely survive the +/// filter would not prove the private touch state this protects. +fn touch_release_clears_egui_touch_state_when_the_library_window_appears_after_begin_pass(phase: egui::TouchPhase) { + let mut h = + Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), [800.0, 600.0], 1.0); + h.settle(T); + let at = egui::pos2(30.0, 40.0); + let touch = |phase| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos: at, force: None }; + h.events.push(touch(egui::TouchPhase::Start)); + h.step(); + assert!(h.view.ctx.input(|i| i.any_touches()), "the touch is down before the window appears"); + let mut problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + h.set_after_raw_input_hook(move |app| app.library_problem = problem.take()); + h.events.push(touch(phase)); + h.step(); + assert!(h.app.library_problem.is_some(), "the window was created after this pass's raw input"); + assert!(!h.view.ctx.input(|i| i.any_touches()), "{phase:?} must clear egui's own touch state, not only stay in the filtered event batch"); +} + +#[test] +fn touch_end_and_cancel_clear_egui_touch_state_when_the_library_window_appears_after_begin_pass() { + touch_release_clears_egui_touch_state_when_the_library_window_appears_after_begin_pass(egui::TouchPhase::End); + touch_release_clears_egui_touch_state_when_the_library_window_appears_after_begin_pass(egui::TouchPhase::Cancel); +} + +fn select_first_photo(h: &mut Headless) -> lightcraft_catalog::PhotoId { + let photo = h.app.session.visible_cloned()[0]; + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + photo +} + +fn press_rating(h: &mut Headless, key: egui::Key) { + for pressed in [true, false] { + h.events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }); + } +} + +fn rating(h: &Headless, photo: lightcraft_catalog::PhotoId) -> u8 { + h.app.session.catalog.photo(photo).expect("the selected photo").rating +} + +/// Take the recorded library-barrier passes (clearing them). A shielded return frame must be +/// visible here even while the global shortcut guard would also keep the rating shortcut from +/// acting: the sequence itself is what these regressions protect. +fn pass_log(h: &mut Headless) -> Vec { + std::mem::take(&mut h.app.library_passes) +} + +/// The frame that just ran returned from a hidden/unsettled blocking window: it must first be a +/// dedicated shield pass that is discarded, then a background pass. The next frame must be +/// ordinary (no repeated transition). Removing the shield pass, its discard or its settlement +/// changes this sequence; the rating shortcut alone would still stay blocked. +fn assert_shielded_return(h: &mut Headless, what: &str) { + use crate::panels::library_problem::LibraryPass; + assert_eq!( + pass_log(h), + vec![LibraryPass::Shield, LibraryPass::Background], + "{what}: the return frame must shield (and discard) the window's first pass, then draw the background" + ); + h.step(); + assert_eq!(pass_log(h), vec![LibraryPass::Background], "{what}: once settled, later frames draw normally without re-shielding"); +} + +/// A notice dismissed after the error window was already settled must shield the first frame that +/// draws the window again. A background rating shortcut injected on that frame would otherwise act +/// through the still-focused/background state while egui's hit test uses the previous pass. +#[test] +fn a_notice_dismissal_shields_the_library_windows_first_return_frame() { + let mut h = Headless::new( + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + let photo = select_first_photo(&mut h); + let before = rating(&h, photo); + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + h.step(); + h.step(); + assert!(has(&h, "button:libraryRetry"), "the error window is settled"); + h.app.notices.push("The settings file could not be read and was reset.".into()); + h.step(); + h.step(); + assert!(has(&h, "button:noticeOk") && !has(&h, "button:libraryRetry"), "the notice hides the settling window"); + h.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.step(); + assert!(h.app.notices.is_empty(), "Escape dismissed the notice"); + // The dismissal happened during the draw; this clears that frame's background-only record. + let _ = pass_log(&mut h); + // This is the first frame in which the window is drawn again. The shortcut must be dropped. + press_rating(&mut h, egui::Key::Num5); + h.step(); + assert_eq!(rating(&h, photo), before, "the first return frame did not let a shortcut act behind the library window"); + assert_shielded_return(&mut h, "notice dismissal"); + assert!(has(&h, "button:libraryRetry"), "the window is back"); + // A settled blocking window keeps owning the keyboard: shortcuts stay blocked on later frames. + press_rating(&mut h, egui::Key::Num5); + h.step(); + h.step(); + assert_eq!(rating(&h, photo), before, "a settled library window did not block a background rating shortcut"); + // Positive control: once the blocking window is dismissed, the same shortcut works. + h.app.library_problem = None; + h.step(); + press_rating(&mut h, egui::Key::Num5); + h.step(); + h.step(); + assert_eq!(rating(&h, photo), 5, "ordinary shortcuts work once the blocking window is gone"); +} + +/// A library window created while a notice is up has never drawn its own shielded pass. Dismissing +/// the notice must not settle it from a frame where it was hidden by the prompt. +#[test] +fn a_library_window_created_under_a_notice_shields_the_first_frame_after_dismissal() { + let mut h = Headless::new( + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + let photo = select_first_photo(&mut h); + let before = rating(&h, photo); + h.app.notices.push("The settings file could not be read and was reset.".into()); + h.step(); + h.step(); + assert!(has(&h, "button:noticeOk")); + let mut problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + h.set_after_raw_input_hook(move |app| app.library_problem = problem.take()); + h.step(); + assert!(has(&h, "button:noticeOk") && !has(&h, "button:libraryRetry"), "the prompt still owns the frame"); + h.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.step(); + assert!(h.app.notices.is_empty()); + let _ = pass_log(&mut h); + press_rating(&mut h, egui::Key::Num5); + h.step(); + assert_eq!(rating(&h, photo), before, "the first frame after dismissal was shielded"); + assert_shielded_return(&mut h, "window created under a notice"); + assert!(has(&h, "button:libraryRetry"), "the window is drawn after its shield pass"); +} + +/// Closing New Library's own name dialog returns the launch list. The list was hidden for the +/// dialog, so the first pass that draws it again must be shielded rather than settling from the +/// dialog-closing pass. +#[test] +fn closing_the_new_library_dialog_shields_the_launch_lists_first_return_frame() { + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + let mut h = Headless::new( + LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { png: None, pick_folder: Some(Box::new(|| -> Option { None })), ..Default::default() }, + ), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + let photo = select_first_photo(&mut h); + let before = rating(&h, photo); + h.app.library_problem = Some(LibraryProblem::choose(&settings)); + h.settle(T); + assert!(has(&h, "button:libraryOpen"), "the launch list is settled"); + assert!(h.app.run("app.newLibrary", json!({})).is_ok(), "New Library opens its name prompt"); + h.step(); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::TextPrompt { .. })), "the name dialog is open"); + assert!(!has(&h, "button:libraryOpen"), "the launch list yields to the dialog it opened"); + let cancel = h + .app + .widgets + .iter() + .rev() + .find(|(id, _)| id == "button:dialogCancel") + .map(|(_, rect)| rect.center()) + .expect("the dialog's Cancel is on screen"); + let press = |pressed| egui::Event::PointerButton { pos: cancel, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(cancel), press(true), press(false)]); + h.step(); + assert!(h.app.ui.dialog.is_none(), "Cancel closed the New Library name dialog"); + // The dialog-closing frame drew the list but must not settle it. + let _ = pass_log(&mut h); + press_rating(&mut h, egui::Key::Num5); + h.step(); + assert_eq!(rating(&h, photo), before, "the launch list's first return frame is shielded"); + assert_shielded_return(&mut h, "New Library dialog cancel"); + assert!(has(&h, "button:libraryOpen"), "the launch list is back"); + // Positive control: dismissing the blocking window restores ordinary shortcuts. + h.app.library_problem = None; + h.step(); + press_rating(&mut h, egui::Key::Num5); + h.step(); + h.step(); + assert_eq!(rating(&h, photo), 5, "ordinary shortcuts work once the launch list is gone"); +} + +/// The banner's Open Library… reopens the window from inside the draw pass, after the background +/// panels already consumed this frame's input. The next frame must be the shielded first frame. +#[test] +fn the_banner_reopen_shields_the_first_input_frame() { + let mut h = Headless::new( + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + let photo = select_first_photo(&mut h); + let before = rating(&h, photo); + h.app.library_problem = Some(LibraryProblem { dismissed: true, ..LibraryProblem::new("/no/such/library", "a test error") }); + h.step(); + h.step(); + assert!(has(&h, "indicator:temporarySession"), "the temporary-session banner is shown"); + let reopen = h + .app + .widgets + .iter() + .rev() + .find(|(id, _)| id == "button:libraryReopen") + .map(|(_, rect)| rect.center()) + .expect("the banner button is on screen"); + let press = |pressed| egui::Event::PointerButton { pos: reopen, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(reopen), press(true), press(false)]); + h.step(); + assert!(h.app.library_problem.as_ref().is_some_and(|p| !p.dismissed), "the banner reopened the window"); + assert!(has(&h, "button:libraryRetry")); + // The reopen happened late in that draw, after its own background pass began. + let _ = pass_log(&mut h); + press_rating(&mut h, egui::Key::Num5); + h.step(); + assert_eq!(rating(&h, photo), before, "input on the frame after a banner reopen was shielded"); + assert_shielded_return(&mut h, "banner reopen"); + assert!(has(&h, "button:libraryRetry"), "the window is back after its shield pass"); + // Positive control: dismissing the blocking window restores ordinary shortcuts. + h.app.library_problem = None; + h.step(); + press_rating(&mut h, egui::Key::Num5); + h.step(); + h.step(); + assert_eq!(rating(&h, photo), 5, "ordinary shortcuts work once the window is gone"); +} + +/// The chooser's own New Library text prompt is not a background barrier: `visible_mode` is None +/// while the dialog is open, so clipboard paste must reach that dialog's focused field. +#[test] +fn clipboard_pastes_into_the_launch_lists_own_text_prompt() { + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + let mut h = Headless::new( + LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { png: None, pick_folder: Some(Box::new(|| -> Option { None })), ..Default::default() }, + ), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + h.app.library_problem = Some(LibraryProblem::choose(&settings)); + h.settle(T); + assert!(h.app.run("app.newLibrary", json!({})).is_ok(), "New Library opens its name prompt"); + h.step(); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::TextPrompt { .. })), "the name dialog is open"); + assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "a dialog opened by the launch list is not a background barrier"); + h.view.clipboard = "pasted-name".into(); + let r = h.request("ui.clipboard", json!({"action": "paste"}), T); + assert_eq!(r["ok"], true, "the chooser's own prompt accepts a paste: {r}"); + h.step(); + let Some(crate::state::Dialog::TextPrompt { value, .. }) = &h.app.ui.dialog else { + panic!("the name dialog closed unexpectedly"); + }; + assert!(value.contains("pasted-name"), "the paste reached the chooser's own text field: {value:?}"); +} + +/// The blocking library window owns input while it is on screen, including over an unrelated +/// dialog it did not open. Disabling only the dialog widgets that handle pointer input is not +/// enough: `dialog_field` must not take the focus back, or egui's text edit keeps consuming host +/// text into the draft behind the window. Several settled barrier frames must stay inert, and the +/// same field must accept typing again once the window is gone. +#[test] +fn a_settled_library_window_keeps_a_background_dialogs_text_draft_unchanged() { + let files = SyntheticLibrary::new("library-text-draft"); + let mut h = Headless::new(files.app(), [1200.0, 760.0], 1.0); + h.settle(T); + h.app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: false }); + for _ in 0..4 { + h.step(); + } + assert!(h.view.ctx.text_edit_focused(), "the dialog's field takes the focus while it is the top modal"); + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + assert!(h.settle(T), "the library window settles over the dialog (pending slots: {:?})", h.app.renderer.pending_slots()); + assert!(has(&h, "button:libraryRetry"), "the library window is laid out"); + assert!(crate::panels::library_problem::blocks_dialog_input(&h.app), "the library window owns input"); + assert!(!h.view.ctx.text_edit_focused(), "the background dialog's field must not regain the focus behind the library window"); + for text in ["leak", "ed", "-draft"] { + h.events.push(egui::Event::Text(text.into())); + h.step(); + let draft = match &h.app.ui.dialog { + Some(crate::state::Dialog::NewAlbum { name, .. }) => name.as_str(), + other => panic!("the background New Album dialog is still open: {other:?}"), + }; + assert_eq!(draft, "Trip", "host text on a settled library-barrier frame must not mutate the dialog draft"); + } + assert!(!h.view.ctx.text_edit_focused(), "several settled barrier frames still leave the field unfocused"); + // Positive control: with the library window gone, the same field takes focus and typing again. + h.app.library_problem = None; + assert!(h.settle(T), "the dialog settles once the library window is gone (pending slots: {:?})", h.app.renderer.pending_slots()); + assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "there is no barrier now"); + assert!(h.view.ctx.text_edit_focused(), "the dialog's field is usable again"); + h.events.push(egui::Event::Text("Holiday".into())); + h.step(); + let draft = match &h.app.ui.dialog { + Some(crate::state::Dialog::NewAlbum { name, .. }) => name.as_str(), + other => panic!("the New Album dialog is still open: {other:?}"), + }; + assert_eq!(draft, "Holiday", "typing reaches the dialog once the library barrier is gone"); +} + +/// The launch list's own New Library name prompt is not a background dialog: `visible_mode` is +/// None while it is open, so the dialog must stay enabled and `dialog_field` must keep giving it +/// the focus and accept host text. +#[test] +fn typing_reaches_the_launch_lists_own_new_library_prompt() { + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + let mut h = Headless::new( + LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { png: None, pick_folder: Some(Box::new(|| -> Option { None })), ..Default::default() }, + ), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + h.app.library_problem = Some(LibraryProblem::choose(&settings)); + h.settle(T); + assert!(h.app.run("app.newLibrary", json!({})).is_ok(), "New Library opens its name prompt"); + h.step(); + h.step(); + assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "the launch list's own prompt is not a background barrier"); + assert!(h.view.ctx.text_edit_focused(), "the prompt's name field has the focus"); + h.events.push(egui::Event::Text("Fresh".into())); + h.step(); + let Some(crate::state::Dialog::TextPrompt { value, command, .. }) = &h.app.ui.dialog else { + panic!("the New Library name dialog closed unexpectedly"); + }; + assert_eq!(command, "app.newLibrary"); + assert_eq!(value, "Fresh", "typing reaches the launch list's own text prompt"); +} diff --git a/crates/ui-egui/src/tests/lifecycle_037.rs b/crates/ui-egui/src/tests/lifecycle_037.rs new file mode 100644 index 000000000..3c4e8009b --- /dev/null +++ b/crates/ui-egui/src/tests/lifecycle_037.rs @@ -0,0 +1,2281 @@ +//! 0.37 lifecycle regressions: the app clock, one-consumer queues, one pending synthetic batch, +//! parent-salted panel state, bundled monochrome emoji, and bounded/stale-safe screenshot capture. + +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::headless::Headless; +use crate::render::{Slot, Tex}; +use crate::tests_fixtures::SyntheticLibrary; +use crate::{ControlResponse, LightcraftApp, MAX_PENDING_SHOTS, MAX_SHOT_BYTES, PendingShot, Services, ShotGeometry}; +use lightcraft_engine::slideshow::Item; + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }) +} + +fn raw(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } +} + +fn key_event(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn pointer_event(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE } +} + +fn accesskit_action(action: egui::accesskit::Action, target_node: egui::accesskit::NodeId) -> egui::Event { + egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest { + action, + target_tree: egui::accesskit::TreeId::ROOT, + target_node, + data: None, + }) +} + +fn viewport_raw(size: egui::Vec2, visible: bool) -> egui::RawInput { + let mut raw = raw(size); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().minimized = Some(!visible); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().occluded = Some(false); + raw +} + +fn reply_channel() -> (Sender, Receiver) { + channel() +} + +fn geometry(width: f32, height: f32) -> ShotGeometry { + ShotGeometry { viewport: egui::vec2(width, height), pixels_per_point: 1.0, zoom: 1.0, epoch: 0 } +} + +fn shot_bytes_for(width: usize, height: usize) -> usize { + width * height * std::mem::size_of::() +} + +/// One layout pass whose texture delta is consumed. 0.37's `TexturesDelta` panics when a non-empty +/// delta is dropped, so a test that only inspects input/state must still acknowledge the font atlas +/// the pass produced. `HeadlessView::run` does this with `TexturesDelta::apply`; a bare +/// `Context::run_ui` has no renderer, so clearing the delta is the explicit handling. +fn frame(ctx: &egui::Context, raw: egui::RawInput, ui: impl FnMut(&mut egui::Ui)) { + let mut output = ctx.run_ui(raw, ui); + output.textures_delta.clear(); +} + +/// An app whose screenshot encoder writes through the real file path and records the pixels it saw. +fn app_with_screenshot_files() -> (LightcraftApp, Arc)>>>) { + let mut app = app(); + let seen = Arc::new(Mutex::new(None)); + let recorder = Arc::clone(&seen); + app.services.png = Some(Box::new(move |img: &lightcraft_raster::Rgba8| { + *recorder.lock().unwrap() = Some((img.width, img.height, img.as_bytes())); + vec![0x89, b'P', b'N', b'G'] + })); + app.services.write = Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))); + app.services.write_new = Some(Box::new(|path: &str, bytes: &[u8]| lightcraft_engine::export::write_file_new(path, bytes))); + (app, seen) +} + +fn temp_png(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + path +} + +/// Bind a pending request to the geometry/library generation a real issue would have used. +fn issue_into_slot(ctx: &egui::Context, shot: &mut PendingShot, issued: ShotGeometry, library: u64) -> egui::ScreenshotCallback { + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES); + shot.slot = Some(slot); + shot.sent_at_ms = Some(0.0); + shot.issued = Some(issued); + shot.issued_library = library; + callback +} + +#[test] +fn bundled_faces_include_monochrome_emoji() { + // 0.37 moved the monochrome emoji faces behind `monochrome_emoji_fonts`, and `default_fonts` + // is no longer an egui default. Both come from the root `egui` features (kittest's own + // dev-dependencies do not propagate), so this test fails if either is lost. + let definitions = crate::theme::font_definitions(&[]); + for name in ["Hack", "Ubuntu-Light", "egui-icons", "NotoEmoji-Regular", "emoji-icon-font"] { + assert!(definitions.font_data.contains_key(name), "bundled face {name} is missing: {:?}", definitions.font_data.keys().collect::>()); + } + let ctx = egui::Context::default(); + ctx.set_fonts(definitions); + // `Fonts` needs a first pass for its pixels-per-point before `fonts_mut` may inspect glyphs. + frame(&ctx, raw(egui::vec2(240.0, 120.0)), |ui| { + // U+1F600 and U+1F44D are in the bundled font cmaps. The older bundled subset + // does not contain U+1F642, so it cannot serve as a feature-wiring oracle. + ui.label("😀👍"); + }); + let font = egui::FontId::proportional(16.0); + assert!(ctx.fonts_mut(|f| f.has_glyph(&font, '\u{1F600}')), "😀 must be paintable from the bundled faces"); + assert!(ctx.fonts_mut(|f| f.has_glyph(&font, '\u{1F44D}')), "👍 must be paintable from the bundled faces"); +} + +#[test] +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"); + }); + }); + let modern = ctx.viewport_id().root_ui_id().with("side-test"); + assert!( + egui::containers::panel::PanelState::load(&ctx, modern).is_some(), + "0.37 stores panel state under parent_ui.scope_id().with_salt(id_salt)" + ); + assert!( + egui::containers::panel::PanelState::load(&ctx, egui::Id::unique("side-test")).is_none(), + "an already-built Id must not be salted a second time (the 0.36 exact-Id key)" + ); +} + +#[test] +fn legacy_panel_state_is_dropped_once_and_modern_keys_survive() { + let (ctx, mut app) = (egui::Context::default(), app()); + let state = || egui::containers::panel::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(10.0, 10.0)) }; + let modern = ctx.viewport_id().root_ui_id(); + let salts = ["top_bar", "bottom_bar", "tool_strip", "library-problem-banner"]; + for salt in salts { + let legacy = egui::Id::unique(salt); + ctx.data_mut(|d| d.insert_persisted(legacy, state())); + ctx.data_mut(|d| d.insert_persisted(modern.with(salt), state())); + } + frame(&ctx, raw(egui::vec2(800.0, 600.0)), |_| {}); + app.logic(&ctx); + for salt in salts { + let legacy = egui::Id::unique(salt); + assert!(egui::containers::panel::PanelState::load(&ctx, legacy).is_none(), "the dead 0.36 key for {salt} is removed"); + assert!(egui::containers::panel::PanelState::load(&ctx, modern.with(salt)).is_some(), "the 0.37 parent-salted key for {salt} is not touched"); + } +} + +#[test] +fn capture_admission_bounds_count_and_bytes_before_issue() { + let (ctx, mut app) = (egui::Context::default(), app()); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + for _ in 0..MAX_PENDING_SHOTS { + let (tx, _rx) = reply_channel(); + app.admit_shot(&ctx, None, false, tx).expect("admission within all limits"); + } + let per_image = shot_bytes_for(100, 100); + assert_eq!(app.shot_bytes, MAX_PENDING_SHOTS * per_image); + let (tx, _rx) = reply_channel(); + assert!(app.admit_shot(&ctx, None, false, tx).is_err(), "a fifth request is refused before issue"); + // The count gate runs before geometry admission. Free the four slots so the next check reaches + // the byte branch instead of answering the count error again. + app.pending_screenshots.clear(); + app.shot_bytes = 0; + + // An image over the per-image cap is refused, and nothing is reserved for it. The geometry is + // inside the texture limit (16 384 × 2 049), so this exercises the byte branch, not `pixels()`. + let before = app.shot_bytes; + app.last_shot_geometry = Some(geometry(16_384.0, 2_049.0)); + let (tx, _rx) = reply_channel(); + let error = app.admit_shot(&ctx, None, false, tx).expect_err("128 MiB + one row is over the per-image cap"); + assert!(error.contains("134283264"), "the error names the image size: {error}"); + assert!(error.contains("134217728"), "the error names the per-image cap: {error}"); + assert_eq!(app.shot_bytes, before, "a refused request reserves no bytes"); + + // Two 100 MB surfaces fit the 128 MiB per-image cap, but a third would exceed the 256 MiB + // aggregate budget (completed-but-undrained images stay counted). + let (ctx, mut app) = (egui::Context::default(), self::app()); + app.last_shot_geometry = Some(geometry(5000.0, 5000.0)); + for _ in 0..2 { + let (tx, _rx) = reply_channel(); + app.admit_shot(&ctx, None, false, tx).expect("100 MB is under the per-image cap"); + } + let (tx, _rx) = reply_channel(); + assert!(app.admit_shot(&ctx, None, false, tx).is_err(), "the aggregate budget bounds a third image"); + assert_eq!(app.shot_bytes, 200_000_000); +} + +#[test] +fn texture_limit_and_missing_surface_have_distinct_admission_errors() { + let (ctx, mut app) = (egui::Context::default(), app()); + app.max_texture_side = 1024; + app.last_shot_geometry = Some(geometry(1200.0, 100.0)); + let (tx, _rx) = reply_channel(); + let error = app.admit_shot(&ctx, None, false, tx).expect_err("wider than the host's texture limit"); + assert!(error.contains("1200×100") && error.contains("1024"), "the error names the surface and the host limit: {error}"); + assert_eq!(app.shot_bytes, 0); + + app.last_shot_geometry = Some(geometry(0.0, 0.0)); + let (tx, _rx) = reply_channel(); + let error = app.admit_shot(&ctx, None, false, tx).expect_err("no laid-out surface"); + assert!(error.contains("no usable surface size"), "the error names the missing layout, not the texture limit: {error}"); + assert_eq!(app.shot_bytes, 0); +} + +#[test] +fn stale_geometry_completion_is_rejected_before_any_write() { + let (ctx, mut app) = (egui::Context::default(), app()); + let path = temp_png("stale-shot"); + let (tx, rx) = reply_channel(); + let issued = ShotGeometry { epoch: 7, ..geometry(100.0, 100.0) }; + app.last_shot_geometry = Some(issued); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("pin the path"); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + let library = app.shot_document_generation(); + let callback = { + let shot = app.pending_screenshots.last_mut().expect("pending"); + issue_into_slot(&ctx, shot, ShotGeometry { epoch: 7, ..geometry(100.0, 100.0) }, library) + }; + callback.complete(Arc::new(egui::ColorImage::filled([2, 2], egui::Color32::WHITE))); + // The window changed size while the callback was in flight: the image belongs to a stale + // generation and must be retried, never written. + app.last_shot_geometry = Some(ShotGeometry { epoch: 8, ..geometry(100.0, 100.0) }); + app.collect_screenshots(&ctx); + assert_eq!(app.pending_screenshots.len(), 1, "the request is retried"); + assert!(app.pending_screenshots[0].slot.is_none(), "the stale slot is dropped"); + assert!(!path.exists(), "a stale image is never written"); + assert!(rx.try_recv().is_err(), "a retry replies only when it succeeds or times out"); + assert_eq!(app.shot_bytes, shot_bytes_for(100, 100), "the reservation is kept for the retry"); +} + +#[test] +fn a_completed_capture_writes_once_and_releases_its_reservation() { + let (ctx, mut app, seen) = { + let (app, seen) = app_with_screenshot_files(); + (egui::Context::default(), app, seen) + }; + let path = temp_png("happy-shot"); + let (tx, rx) = reply_channel(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("pin the path"); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + let issued = geometry(100.0, 100.0); + let generation = app.shot_document_generation(); + let callback = { + let shot = app.pending_screenshots.last_mut().expect("pending"); + issue_into_slot(&ctx, shot, issued, generation) + }; + callback.complete(Arc::new(egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3)))); + app.collect_screenshots(&ctx); + + let reply = rx.try_recv().expect("exactly one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 2); + assert_eq!(reply["result"]["height"], 2); + assert_eq!(std::fs::read(&path).expect("the file is written"), vec![0x89, b'P', b'N', b'G']); + let seen = seen.lock().unwrap().clone().expect("the encoder ran"); + assert_eq!((seen.0, seen.1), (2, 2)); + assert_eq!(seen.2, vec![1, 2, 3, 255, 1, 2, 3, 255, 1, 2, 3, 255, 1, 2, 3, 255]); + assert!(app.pending_screenshots.is_empty()); + assert_eq!(app.shot_bytes, 0, "success returns the reservation"); + assert!(rx.try_recv().is_err(), "one reply, not two"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_capture_completed_after_a_catalog_edit_is_still_written() { + // Catalog edits bump `catalog.revision` on every op; that is content, not document identity. + // Binding the capture to it retried once per edit and could only end in a timeout. + let (ctx, mut app, _seen) = { + let (app, seen) = app_with_screenshot_files(); + (egui::Context::default(), app, seen) + }; + let path = temp_png("edited-shot"); + let (tx, rx) = reply_channel(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("pin the path"); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + let generation = app.shot_document_generation(); + let issued = geometry(100.0, 100.0); + let callback = { + let shot = app.pending_screenshots.last_mut().expect("pending"); + issue_into_slot(&ctx, shot, issued, generation) + }; + app.session.catalog.revision = app.session.catalog.revision.wrapping_add(1); + assert_eq!(app.shot_document_generation(), generation, "an edit is not a new library"); + callback.complete(Arc::new(egui::ColorImage::filled([2, 2], egui::Color32::WHITE))); + app.collect_screenshots(&ctx); + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(path.exists()); + assert_eq!(app.shot_bytes, 0); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn headless_capture_finishes_once_and_releases_only_its_reservation() { + for case in ["success", "overflow", "image-cap", "aggregate-cap"] { + let (mut app, seen) = app_with_screenshot_files(); + let ctx = egui::Context::default(); + let path = temp_png(&format!("headless-finish-{case}")); + let (tx, rx) = reply_channel(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("pin path"); + app.admit_shot(&ctx, Some(target), true, tx).expect("admit"); + let mut shot = app.pending_screenshots.pop().expect("pending shot"); + let mut image = egui::ColorImage::filled([200, 200], egui::Color32::WHITE); + let other_reservations = if case == "aggregate-cap" { crate::MAX_SHOT_TOTAL_BYTES - shot.reserved } else { 0 }; + app.shot_bytes += other_reservations; + if case == "overflow" { + image.size = [usize::MAX, 2]; + } else if case == "image-cap" { + image.size = [MAX_SHOT_BYTES, 1]; + } + app.finish_headless_shot(&mut shot, &image); + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], case == "success", "{case}: {reply}"); + match case { + "overflow" => assert_eq!(reply["error"], "ui.screenshot: the rendered image has invalid dimensions"), + "image-cap" => assert!(reply["error"].as_str().expect("error message").contains("per-image limit")), + "aggregate-cap" => assert!(reply["error"].as_str().expect("error message").contains("bytes reserved is over")), + _ => {} + } + assert!(rx.try_recv().is_err(), "no duplicate reply"); + assert_eq!(app.shot_bytes, other_reservations, "{case}"); + assert_eq!(path.exists(), case == "success", "{case}"); + assert_eq!(seen.lock().unwrap().is_some(), case == "success", "{case}"); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn an_image_larger_than_its_reservation_is_grown_and_written() { + // Wayland has no `inner_rect`, so on a hidden tick the reservation can be based on the old + // content rect while the compositor returns the resized surface. A legal image is not an error. + let (ctx, mut app, _seen) = { + let (app, seen) = app_with_screenshot_files(); + (egui::Context::default(), app, seen) + }; + let path = temp_png("grown-shot"); + let (tx, rx) = reply_channel(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("pin the path"); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + let issued = geometry(100.0, 100.0); + let generation = app.shot_document_generation(); + let callback = { + let shot = app.pending_screenshots.last_mut().expect("pending"); + issue_into_slot(&ctx, shot, issued, generation) + }; + callback.complete(Arc::new(egui::ColorImage::filled([200, 200], egui::Color32::WHITE))); + app.collect_screenshots(&ctx); + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 200); + assert_eq!(app.shot_bytes, 0, "the grown reservation is released too"); + assert!(path.exists()); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_surface_that_grew_past_the_per_image_cap_is_refused_by_issue() { + let (ctx, mut app) = (egui::Context::default(), app()); + app.set_simulated_clock(Some(0.0)); + app.ui_passes = 3; + app.fonts_ready = true; + app.last_shot_geometry = Some(geometry(5000.0, 5000.0)); + let (tx, rx) = reply_channel(); + app.admit_shot(&ctx, None, false, tx).expect("100 MB is admitted"); + app.set_simulated_clock(Some(1.0)); + app.ui_passes = 6; + // The window grew after admission: 7000² × 4 = 196 MB, over the 128 MiB per-image cap. + app.last_shot_geometry = Some(geometry(7000.0, 7000.0)); + app.issue_screenshots(&ctx); + let reply = rx.try_recv().expect("the refusal is a reply"); + assert_eq!(reply["ok"], false, "{reply}"); + let error = reply["error"].as_str().unwrap_or_default(); + assert!(error.contains("196000000"), "the error names the new image, not the delta: {error}"); + assert!(error.contains("134217728"), "{error}"); + assert!(app.pending_screenshots.is_empty()); + assert_eq!(app.shot_bytes, 0); +} + +#[test] +fn timeout_replies_once_and_a_late_callback_is_inert() { + let (ctx, mut app) = (egui::Context::default(), app()); + app.set_simulated_clock(Some(0.0)); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let (tx, rx) = reply_channel(); + app.admit_shot(&ctx, None, false, tx).expect("admitted"); + let issued = geometry(100.0, 100.0); + let generation = app.shot_document_generation(); + let callback = { + let shot = app.pending_screenshots.last_mut().expect("pending"); + issue_into_slot(&ctx, shot, issued, generation) + }; + app.set_simulated_clock(Some(10.0)); + app.collect_screenshots(&ctx); + let reply = rx.try_recv().expect("the timeout reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("timed out"), "{reply}"); + assert!(app.pending_screenshots.is_empty()); + assert_eq!(app.shot_bytes, 0, "a timed-out request returns its reservation"); + // The renderer may still deliver the cancelled one-shot: it finds no slot and changes nothing. + callback.complete(Arc::new(egui::ColorImage::filled([2, 2], egui::Color32::WHITE))); + app.collect_screenshots(&ctx); + assert!(rx.try_recv().is_err(), "no second reply"); + assert_eq!(app.shot_bytes, 0); +} + +#[test] +fn a_busy_renderer_cannot_turn_a_settled_capture_into_a_timeout() { + let (tx, _rx) = reply_channel(); + let shot = PendingShot { + token: 1, + target: None, + reply: tx, + headless: false, + admitted_passes: 3, + not_before_ms: 120.0, + force_ms: 3000.0, + deadline_ms: 5120.0, + sent_at_ms: None, + reserved: 1, + slot: None, + issued: None, + issued_library: 0, + }; + assert!(!crate::shot_ready(100.0, &shot, false, 6), "settle time has not passed"); + assert!(crate::shot_ready(200.0, &shot, false, 6), "an idle renderer captures as soon as it is settled"); + assert!(!crate::shot_ready(200.0, &shot, true, 6), "a busy renderer still waits for the settle window"); + assert!(crate::shot_ready(3000.0, &shot, true, 6), "the forced instant captures the current frame anyway"); +} + +#[test] +fn a_silent_compositor_falls_back_to_the_headless_capture_once() { + let (ctx, mut app) = (egui::Context::default(), app()); + frame(&ctx, raw(egui::vec2(800.0, 600.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + app.set_simulated_clock(Some(0.0)); + app.last_shot_geometry = Some(geometry(160.0, 100.0)); + app.ui_passes = 6; + let (tx, rx) = reply_channel(); + app.admit_shot(&ctx, None, false, tx).expect("a windowed request is admitted"); + let issued = geometry(160.0, 100.0); + let generation = app.shot_document_generation(); + let callback = { + let shot = app.pending_screenshots.last_mut().expect("pending"); + issue_into_slot(&ctx, shot, issued, generation) + }; + app.ui_passes = 9; + // Two seconds after the command went out, no compositor frame has arrived. + app.set_simulated_clock(Some(2.5)); + app.issue_screenshots(&ctx); + let reply = rx.try_recv().expect("the fallback answers"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(app.pending_screenshots.is_empty()); + assert_eq!(app.shot_bytes, 0); + // The canceled compositor callback may still fire; it must not produce a second reply. + callback.complete(Arc::new(egui::ColorImage::filled([2, 2], egui::Color32::WHITE))); + app.collect_screenshots(&ctx); + assert!(rx.try_recv().is_err(), "one reply from the fallback"); +} + +#[test] +fn a_busy_renderer_still_answers_a_headless_capture_at_the_forced_instant() { + let (ctx, mut app) = (egui::Context::default(), app()); + // Install fonts and reach the "ready" state a real host has after its setup frame. + frame(&ctx, raw(egui::vec2(800.0, 600.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready, "fonts are installed for the shadow frame"); + app.set_simulated_clock(Some(0.0)); + app.last_shot_geometry = Some(geometry(160.0, 100.0)); + app.ui_passes = 10; + let (tx, rx) = reply_channel(); + app.admit_shot(&ctx, None, true, tx).expect("a headless request is admitted"); + app.ui_passes = 13; + app.renderer.force_in_flight_for_test(1); + app.set_simulated_clock(Some(1.0)); + app.issue_screenshots(&ctx); + assert!(rx.try_recv().is_err(), "a busy renderer defers the capture before the forced instant"); + assert_eq!(app.pending_screenshots.len(), 1); + app.set_simulated_clock(Some(4.0)); + app.issue_screenshots(&ctx); + let reply = rx.try_recv().expect("the forced instant answers, it does not time out"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(app.pending_screenshots.is_empty()); + assert_eq!(app.shot_bytes, 0, "the grown/shrunk reservation is released"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug)] +struct TestDropped(std::path::PathBuf); + +#[cfg(not(target_arch = "wasm32"))] +impl egui::DroppedFile for TestDropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(vec![]) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn one_synthetic_batch_until_a_completed_pass_and_drops_wait_for_the_ui_phase() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(4.0, 4.0))); + let mut first = raw(egui::vec2(800.0, 600.0)); + app.raw_input_hook(&ctx, &mut first); + assert_eq!(first.events.len(), 1, "one pointer move is injected"); + assert!(app.synthetic_in_flight); + let mut second = raw(egui::vec2(800.0, 600.0)); + app.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "no second step while the first waits for a UI pass"); + + // Drops are queued by the hook even while a batch is in flight, but only the UI phase takes them. + second.dropped_files.push(Arc::new(TestDropped(std::env::temp_dir().join("lightcraft-drop.txt")))); + app.raw_input_hook(&ctx, &mut second); + assert_eq!(app.dropped_files.len(), 1); + frame(&ctx, raw(egui::vec2(800.0, 600.0)), |_| {}); + app.logic(&ctx); + app.logic(&ctx); + assert_eq!(app.dropped_files.len(), 1, "a hidden logic tick never replays the drop"); + assert_eq!(app.take_dropped_files().len(), 1, "the UI phase takes the queued drop exactly once"); + assert!(app.dropped_files.is_empty()); + + // A completed, nondiscarded app pass (not a bare `run_ui` closure) ends the in-flight batch, so + // the next host frame injects the next synthetic step. This fails if `finish_ui_pass` stops + // clearing the gate. + frame(&ctx, raw(egui::vec2(800.0, 600.0)), |ui| app.ui(ui)); + assert!(!app.synthetic_in_flight, "a completed UI pass consumed the batch"); + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(9.0, 9.0))); + let mut third = raw(egui::vec2(800.0, 600.0)); + app.raw_input_hook(&ctx, &mut third); + assert_eq!(third.events.len(), 1, "the next batch is injected after the completed pass"); +} + +#[test] +fn first_headless_frame_runs_logic_and_uses_its_simulated_clock() { + let mut h = Headless::new(app(), [800.0, 600.0], 1.0); + assert_eq!(h.time(), 0.0); + h.step(); + assert!(h.app.styled, "the first frame at t=0 runs logic and installs fonts"); + assert_eq!(h.app.tick_now(), 0.0, "the app clock follows the headless host's simulated time"); + h.step(); + h.step(); + assert!(h.app.fonts_ready, "fonts are ready after the setup pass"); + assert_eq!(h.app.ui_passes, 2, "exactly the two steps after the setup pass were laid out"); + assert!(h.app.tick_now() > 0.0, "the simulated clock advanced with the steps"); +} + +#[test] +fn import_ticks_once_per_host_frame_and_once_per_hidden_tick() { + let ctx = egui::Context::default(); + let seen = std::cell::RefCell::new(Vec::new()); + frame(&ctx, raw(egui::vec2(800.0, 600.0)), |ui| { + seen.borrow_mut().push(crate::import_tick_allowed(ui.ctx())); + if seen.borrow().len() == 1 { + ui.ctx().request_discard("test: force a second layout pass"); + } + }); + assert_eq!(*seen.borrow(), vec![true, false], "the first pass commits import batches; the discarded re-run does not"); + + let hidden = viewport_raw(egui::vec2(800.0, 600.0), false); + let mut allowed = false; + let _ = ctx.run_logic(&hidden, |ctx| allowed = crate::import_tick_allowed(ctx)); + assert!(allowed, "a hidden logic tick reports pass index 0 and must still commit import batches"); +} + +#[test] +fn hidden_slideshow_pauses_and_keeps_its_remaining_time() { + let (ctx, mut app) = (egui::Context::default(), app()); + app.set_simulated_clock(Some(10.0)); + let started = crate::panels::slideshow::start( + &mut app, + &serde_json::json!({"slideDuration": 2.0, "fadeDuration": 0.0, "randomOrder": false, "repeat": false}), + 10.0, + ); + assert!(started.is_ok(), "the demo library has slides: {started:?}"); + // Every slide is "ready": without the hidden gate, 10 hidden seconds would advance the show. + for id in app.session.visible_cloned() { + let tex = ctx.load_texture(format!("slide-{}", id.0), egui::ColorImage::filled([2, 2], egui::Color32::WHITE), Default::default()); + app.renderer.textures.insert( + Slot::Slide(Item::Photo(id)), + Tex { look: None, key: 0, photo: id, tex, size: [2, 2], histogram: None, ms: 0.0, quick: None, pixels: None }, + ); + } + let first = app.ui.slideshow.as_ref().unwrap().play.current(); + let selection = app.session.active(); + app.set_simulated_clock(Some(10.5)); + let remaining = app.ui.slideshow.as_ref().unwrap().play.remaining(10.5); + assert!(remaining > 0.0); + let pending_before = app.renderer.pending_slots(); + + let hidden = viewport_raw(egui::vec2(800.0, 600.0), false); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.ui.slideshow.as_ref().unwrap().play.is_paused(), "hiding auto-pauses playback"); + app.set_simulated_clock(Some(20.0)); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + { + let play = &app.ui.slideshow.as_ref().unwrap().play; + assert_eq!(play.current(), first, "a hidden slideshow does not advance"); + assert!((play.remaining(20.0) - remaining).abs() < 1e-6, "the slide keeps its remaining time while hidden"); + } + assert_eq!(app.session.active(), selection, "a hidden slideshow does not move the selection"); + assert_eq!(app.renderer.pending_slots(), pending_before, "a hidden slideshow requests no new slide renders"); + + let visible = viewport_raw(egui::vec2(800.0, 600.0), true); + app.set_simulated_clock(Some(20.0)); + let _ = ctx.run_logic(&visible, |ctx| app.logic(ctx)); + let play = &app.ui.slideshow.as_ref().unwrap().play; + assert!(!play.is_paused(), "showing the window resumes playback"); + assert_eq!(play.current(), first); + assert!((play.remaining(20.0) - remaining).abs() < 1e-6, "resuming does not skip the hidden minutes"); +} + +#[test] +fn text_field_inherits_ime_delete_surrounding_bytes_exactly() { + // The pinned 0.37 fork sends winit's incoming byte deletion as + // `ImeEvent::DeleteSurroundingBytes`; the app's `TextField` wraps the built-in `TextEdit`, + // so it must apply the range exactly (not extend it to a grapheme cluster). + let ctx = egui::Context::default(); + let id = crate::text_field::id("ime-bytes"); + let mut text = String::from("abc"); + let pass = |events: Vec, text: &mut String| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 200.0))), events, ..Default::default() }; + frame(&ctx, raw, |ui| { + ui.memory_mut(|memory| memory.request_focus(id)); + let _ = crate::text_field::TextField::singleline("ime-bytes", text).show(ui); + }); + }; + pass(vec![], &mut text); + pass(vec![], &mut text); + let mut state = egui::text_edit::TextEditState::load(&ctx, id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(3)))); + state.store(&ctx, id); + pass(vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 2, after_bytes: 0 })], &mut text); + assert_eq!(text, "a", "the reported UTF-8 range is deleted exactly"); + + // The same path must not split a multi-byte codepoint when the IME reports its whole width. + let mut text = String::from("aé🙂z"); + pass(vec![], &mut text); + pass(vec![], &mut text); + let mut state = egui::text_edit::TextEditState::load(&ctx, id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(3)))); // after 🙂 + state.store(&ctx, id); + pass(vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 4, after_bytes: 0 })], &mut text); + assert_eq!(text, "aéz", "the whole emoji is removed without touching the combining-free neighbours"); +} + +#[test] +fn text_field_ime_commit_replaces_the_selection() { + let ctx = egui::Context::default(); + let id = crate::text_field::id("ime-commit"); + let mut text = String::from("abc"); + let pass = |events: Vec, text: &mut String| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 200.0))), events, ..Default::default() }; + frame(&ctx, raw, |ui| { + ui.memory_mut(|memory| memory.request_focus(id)); + let _ = crate::text_field::TextField::singleline("ime-commit", text).show(ui); + }); + }; + pass(vec![], &mut text); + pass(vec![], &mut text); + let mut state = egui::text_edit::TextEditState::load(&ctx, id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(2)))); + state.store(&ctx, id); + pass(vec![egui::Event::Ime(egui::ImeEvent::Commit("é".into()))], &mut text); + assert_eq!(text, "éc", "the committed text replaces the selected range once"); +} + +fn shot_dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lightcraft-shot-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("temp screenshot folder"); + d +} + +/// Complete one admitted capture whose slot is bound to the current geometry. +fn complete_admitted(ctx: &egui::Context, app: &mut LightcraftApp, image: egui::ColorImage) { + let generation = app.shot_document_generation(); + let issued = geometry(100.0, 100.0); + let callback = { + let shot = app.pending_screenshots.last_mut().expect("pending"); + issue_into_slot(ctx, shot, issued, generation) + }; + callback.complete(Arc::new(image)); + app.collect_screenshots(ctx); +} + +#[test] +fn a_new_file_at_an_absent_screenshot_path_survives() { + let dir = shot_dir("absent-race"); + let path = dir.join("shot.png"); + let (mut app, _seen) = app_with_screenshot_files(); + let ctx = egui::Context::default(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let (tx, rx) = reply_channel(); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("the path is free"); + assert!(target.was_absent()); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + // A photo (or any other file) appears at the path while the capture is queued. + std::fs::write(&path, b"new original").expect("created while queued"); + complete_admitted(&ctx, &mut app, egui::ColorImage::filled([2, 2], egui::Color32::WHITE)); + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("appeared"), "{reply}"); + assert_eq!(std::fs::read(&path).expect("the new file is still there"), b"new original"); + assert_eq!(app.shot_bytes, 0, "the reservation is released on refusal"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_absent_screenshot_path_that_becomes_a_hard_link_to_an_original_survives() { + let dir = shot_dir("absent-original"); + let original = dir.join("IMG_1.jpg"); + let path = dir.join("shot.png"); + std::fs::write(&original, b"original").expect("original"); + let (mut app, _seen) = app_with_screenshot_files(); + let ctx = egui::Context::default(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let (tx, rx) = reply_channel(); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("the path is free"); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + std::fs::hard_link(&original, &path).expect("an imported hard link appears while queued"); + complete_admitted(&ctx, &mut app, egui::ColorImage::filled([2, 2], egui::Color32::WHITE)); + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(std::fs::read(&original).expect("original survives"), b"original"); + assert_eq!(std::fs::read(&path).expect("the link still names the original"), b"original"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_existing_allowed_screenshot_file_is_still_overwritten() { + let dir = shot_dir("existing-allowed"); + let path = dir.join("shot.png"); + std::fs::write(&path, b"old screenshot").expect("old file"); + let (mut app, _seen) = app_with_screenshot_files(); + let ctx = egui::Context::default(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let (tx, rx) = reply_channel(); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("ordinary existing file"); + assert!(!target.was_absent()); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + complete_admitted(&ctx, &mut app, egui::ColorImage::filled([2, 2], egui::Color32::WHITE)); + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(std::fs::read(&path).expect("written"), vec![0x89, b'P', b'N', b'G']); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn a_screenshot_parent_retargeted_while_queued_is_not_written() { + use std::os::unix::fs::symlink; + + let dir = shot_dir("parent-retargeted"); + let admitted = dir.join("admitted"); + let unexpected = dir.join("unexpected"); + let link = dir.join("destination"); + std::fs::create_dir(&admitted).expect("admitted destination folder"); + std::fs::create_dir(&unexpected).expect("unexpected destination folder"); + symlink(&admitted, &link).expect("destination folder link"); + let path = link.join("shot.png"); + let (mut app, seen) = app_with_screenshot_files(); + let ctx = egui::Context::default(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let (tx, rx) = reply_channel(); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("free path through a folder link"); + assert!(target.was_absent()); + assert!(app.session.revalidate_write_target(&target).is_ok(), "an unchanged folder link is a valid destination"); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + + // The path's spelling is unchanged, but it now resolves to another folder while the real + // capture is pending. Re-checking only the saved resolved folder would allow this write. + std::fs::remove_file(&link).expect("unlink only the test folder link"); + symlink(&unexpected, &link).expect("retarget within the test temp folder"); + complete_admitted(&ctx, &mut app, egui::ColorImage::filled([2, 2], egui::Color32::WHITE)); + + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("destination folder changed"), "{reply}"); + assert!(!admitted.join("shot.png").exists(), "the refused capture writes nothing to the admitted folder"); + assert!(!unexpected.join("shot.png").exists(), "the refused capture writes nothing to the retargeted folder"); + assert!(seen.lock().unwrap().is_none(), "a changed destination is refused before encoding"); + assert!(app.pending_screenshots.is_empty()); + assert_eq!(app.shot_bytes, 0, "the refused capture releases its reservation"); + assert!(rx.try_recv().is_err(), "the refused capture has exactly one reply"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn an_existing_screenshot_target_replaced_while_queued_is_not_overwritten() { + let dir = shot_dir("existing-replaced"); + let path = dir.join("shot.png"); + let keep = dir.join("keep.png"); + let other = dir.join("other.png"); + std::fs::write(&path, b"old screenshot").expect("old file"); + let (mut app, _seen) = app_with_screenshot_files(); + let ctx = egui::Context::default(); + app.last_shot_geometry = Some(geometry(100.0, 100.0)); + let (tx, rx) = reply_channel(); + let target = app.session.pin_write_target(&path.to_string_lossy()).expect("ordinary existing file"); + app.admit_shot(&ctx, Some(target), false, tx).expect("admitted"); + // Keep the admitted inode alive, then replace the directory entry with a different file. + std::fs::hard_link(&path, &keep).expect("keep the admitted inode"); + std::fs::write(&other, b"new file").expect("new file"); + std::fs::rename(&other, &path).expect("replace the entry"); + complete_admitted(&ctx, &mut app, egui::ColorImage::filled([2, 2], egui::Color32::WHITE)); + let reply = rx.try_recv().expect("one reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("changed"), "{reply}"); + assert_eq!(std::fs::read(&path).expect("the replacing file survives"), b"new file"); + assert_eq!(std::fs::read(&keep).expect("the admitted file survives"), b"old screenshot"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn ui_scroll_carries_validated_unit_phase_and_source() { + let (mut app, ctx) = (app(), egui::Context::default()); + let outcome = crate::control::gesture_events( + &mut app, + &ctx, + "ui.scroll", + &serde_json::json!({"source": "wheel", "unit": "line", "phase": "start", "dy": 1.0}), + ) + .expect("a wheel line step is valid"); + assert_eq!(outcome.result, serde_json::Value::Null); + assert!(outcome.target.is_none(), "scrolling has no widget target to revalidate"); + match app.synthetic.first().expect("one wheel event") { + egui::Event::MouseWheel { unit, phase, source, .. } => { + assert_eq!(*unit, egui::MouseWheelUnit::Line); + assert_eq!(*phase, egui::TouchPhase::Start); + assert_eq!(*source, egui::MouseWheelSource::Wheel); + } + other => panic!("expected a wheel event, got {other:?}"), + } + + app.synthetic.clear(); + crate::control::gesture_events( + &mut app, + &ctx, + "ui.scroll", + &serde_json::json!({"source": "trackpad", "unit": "point", "phase": "end", "dy": -12.0}), + ) + .expect("a trackpad end event is valid"); + match app.synthetic.first().expect("one wheel event") { + egui::Event::MouseWheel { unit, phase, source, .. } => { + assert_eq!(*unit, egui::MouseWheelUnit::Point); + assert_eq!(*phase, egui::TouchPhase::End); + assert_eq!(*source, egui::MouseWheelSource::Trackpad); + } + other => panic!("expected a wheel event, got {other:?}"), + } + + // Omitted keys keep the pre-0.37 request meaning. + app.synthetic.clear(); + crate::control::gesture_events(&mut app, &ctx, "ui.scroll", &serde_json::json!({"dy": 3.0})).expect("defaults are valid"); + match app.synthetic.first().expect("one wheel event") { + egui::Event::MouseWheel { unit, phase, source, .. } => { + assert_eq!(*unit, egui::MouseWheelUnit::Point); + assert_eq!(*phase, egui::TouchPhase::Move); + assert_eq!(*source, egui::MouseWheelSource::Unknown); + } + other => panic!("expected a wheel event, got {other:?}"), + } + + // An invalid value is an error and queues nothing. + app.synthetic.clear(); + let error = crate::control::gesture_events(&mut app, &ctx, "ui.scroll", &serde_json::json!({"unit": "notch"})).expect_err("unknown unit"); + assert!(error.contains("unit"), "{error}"); + assert!(app.synthetic.is_empty()); +} + +#[test] +fn synthetic_pointer_requests_bound_clicks_and_drag_steps() { + let (mut app, ctx) = (app(), egui::Context::default()); + app.widgets.push(("bounded:test".into(), egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(20.0, 20.0)))); + let max_clicks = crate::control::MAX_CLICKS; + let max_steps = crate::control::MAX_DRAG_STEPS; + + for (method, params) in [ + ("ui.clickWidget", serde_json::json!({"id": "bounded:test", "count": u64::MAX})), + ("ui.click", serde_json::json!({"x": 1.0, "y": 1.0, "count": u64::MAX})), + ] { + app.synthetic.clear(); + crate::control::gesture_events(&mut app, &ctx, method, ¶ms).expect("a huge click count is bounded, not refused"); + assert_eq!(app.synthetic.len(), 1 + 2 * max_clicks as usize, "{method}: one move and two events per click"); + let presses = app.synthetic.iter().filter(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. })).count(); + assert_eq!(presses, max_clicks as usize, "{method}: the click count is capped"); + } + + for (method, params) in [ + ("ui.dragWidget", serde_json::json!({"id": "bounded:test", "toX": 50.0, "toY": 50.0, "steps": u64::MAX})), + ("ui.drag", serde_json::json!({"x": 1.0, "y": 1.0, "toX": 50.0, "toY": 50.0, "steps": u64::MAX})), + ] { + app.synthetic.clear(); + crate::control::gesture_events(&mut app, &ctx, method, ¶ms).expect("a huge drag step count is bounded, not refused"); + assert_eq!(app.synthetic.len(), 3 + max_steps as usize, "{method}: press, release and the capped moves"); + let moves = app.synthetic.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count(); + assert_eq!(moves, 1 + max_steps as usize, "{method}: the step count is capped"); + } +} + +#[test] +fn a_gesture_never_uses_the_previous_widget_registry() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + app.widgets.push(("stale:test".into(), egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(10.0, 10.0)))); + let (tx, rx) = reply_channel(); + app.admit_gesture(&ctx, "ui.clickWidget".into(), serde_json::json!({"id": "stale:test"}), tx).expect("admitted"); + assert!(rx.try_recv().is_err(), "nothing is answered at admission"); + // A completed pass replaces the registry; the stale id is not in the fresh one. + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + let reply = rx.try_recv().expect("the resolution error answers once"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("no widget"), "{reply}"); + assert!(app.pending_gestures.is_empty()); + assert!(app.synthetic.is_empty()); +} + +#[test] +fn a_gesture_cancels_when_the_view_changes_before_the_fresh_layout() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + let (tx, rx) = reply_channel(); + app.admit_gesture(&ctx, "ui.move".into(), serde_json::json!({"x": 10.0, "y": 10.0}), tx).expect("admitted"); + // the app opens in Detail, so a cancellation test must move to a genuinely different view + app.ui.view = crate::state::ViewMode::PhotoGrid; + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + let reply = rx.try_recv().expect("the cancel error answers once"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("canceled"), "{reply}"); + assert!(app.synthetic.is_empty()); +} + +#[test] +fn a_widget_gesture_cancels_if_its_target_moves_between_resolution_and_the_press() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + // Resolve a click against a completed layout at the widget's old place. + app.widgets.push(("moving:test".into(), egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(30.0, 20.0)))); + let (tx, rx) = reply_channel(); + app.admit_gesture(&ctx, "ui.clickWidget".into(), serde_json::json!({"id": "moving:test"}), tx).expect("admitted"); + app.layout_epoch = app.layout_epoch.wrapping_add(1); + app.resolve_pending_gestures(&ctx); + assert_eq!(app.synthetic.len(), 3, "move, press and release are queued"); + assert!(rx.try_recv().is_err(), "nothing is answered at resolution"); + // The first pass consumes the move; the pointer now rests at the resolved point. + let mut next = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut next); + assert!(next.events.iter().any(|e| matches!(e, egui::Event::PointerMoved(p) if *p == egui::pos2(25.0, 20.0)))); + app.synthetic_in_flight = false; + // The next completed layout puts the widget somewhere else. egui would attribute the press + // in the following pass to whatever sits at the old coordinates; fail closed instead. + app.widgets = vec![("moving:test".into(), egui::Rect::from_min_size(egui::pos2(100.0, 10.0), egui::vec2(30.0, 20.0)))]; + let mut next = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut next); + assert!(next.events.is_empty(), "no press is delivered at the stale point"); + assert!(app.synthetic.is_empty(), "the rest of the canceled click is dropped"); + let reply = rx.try_recv().expect("the moved target answers once"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("moving:test"), "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("nothing was retargeted"), "{reply}"); + assert!(app.pending_gestures.is_empty()); +} + +#[test] +fn a_maximum_step_drag_is_delivered_past_the_five_second_stall_limit() { + let mut h = Headless::new(app(), [900.0, 650.0], 1.0); + h.step(); + h.step(); + assert!(h.app.fonts_ready); + // A capped drag is delivered one event per host frame. At 1/60 s each the whole gesture takes + // far longer than GESTURE_TIMEOUT_MS, so only a deadline refreshed by real progress lets it + // finish; the old absolute deadline failed it (and every queued gesture) halfway through. + let r = h.request( + "ui.drag", + serde_json::json!({ + "x": 30.0, + "y": 30.0, + "toX": 30.0, + "toY": 30.0, + "steps": crate::control::MAX_DRAG_STEPS, + }), + Duration::from_secs(90), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.time() > 5.0, "the drag's simulated delivery outlived the old 5 s deadline: {}", h.time()); + assert!(h.app.pending_gestures.is_empty()); + assert!(h.app.synthetic.is_empty()); +} + +#[test] +fn a_click_on_a_control_under_a_nonmodal_overlay_is_refused_without_activating_the_overlay() { + let files = SyntheticLibrary::new("control-under-overlay"); + let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0); + let photo = h.app.session.visible_cloned()[0]; + let t = Duration::from_secs(20); + assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true); + assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true); + h.app.ui.open_sections = vec!["light".to_string()]; + assert!(h.settle(Duration::from_secs(60)), "the edit panel settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "slider:light.exposure"; + assert!(h.app.widgets.iter().any(|(widget, _)| widget == id), "{id} is on screen"); + let before = h.app.session.develop_of(photo).expect("its develop settings").light.exposure; + // The quit prompt's Foreground dim and window own the pointer (and the notice barrier also + // covers the control channel's own hit check). The settings slider remains registered + // underneath it, and its rect still contains the press point. + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + assert!(h.app.widgets.iter().any(|(widget, _)| widget == "button:quitCancel"), "the overlay is shown"); + let r = h.request("ui.clickWidget", serde_json::json!({"id": id}), Duration::from_secs(20)); + assert_eq!(r["ok"], false, "{r}"); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains(id), "{r}"); + assert!(error.contains("no longer visible"), "{r}"); + assert!(error.contains("nothing was retargeted"), "{r}"); + assert!(h.app.quit_prompt.is_some(), "the click did not activate (or dismiss) the overlay"); + let r = h.request("ui.dragWidget", serde_json::json!({"id": id, "dx": 12.0, "steps": 4}), Duration::from_secs(20)); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("before its press"), "{r}"); + let after = h.app.session.develop_of(photo).expect("its develop settings").light.exposure; + assert_eq!(after, before, "the covered slider did not change"); + assert!(h.app.quit_prompt.is_some(), "the drag did not activate (or dismiss) the overlay"); + assert!(h.app.pending_gestures.is_empty()); + assert!(h.app.synthetic.is_empty()); +} + +/// The release ownership check must not reject an ordinary unobstructed click, including a click +/// on a sub-rect of a larger widget (the bottom rating row registers each star, not the row's +/// interactive centre). This is the positive control the hover-only probe lacked. +#[test] +fn an_unobstructed_click_on_a_subrect_reaches_the_widget() { + let files = SyntheticLibrary::new("unobstructed-subrect"); + let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0); + let photo = h.app.session.visible_cloned()[0]; + let t = Duration::from_secs(20); + assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true); + assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true); + assert!(h.settle(Duration::from_secs(60)), "the detail view settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "star:bottom:2"; + assert!(h.app.widgets.iter().any(|(widget, _)| widget == id), "{id} is on screen"); + assert!(h.app.widget_hits.iter().any(|(widget, _)| widget == id), "{id} has a probe"); + let r = h.request("ui.clickWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.catalog.photo(photo).expect("the photo").rating, 2, "the sub-rect click reached the rating row"); +} + +/// A non-modal overlay that appears after the press must cancel the click before its release and +/// must not turn the release into an overlay click; the pointer is released away from the target. +#[test] +fn an_overlay_that_appears_after_the_press_cancels_the_release() { + let files = SyntheticLibrary::new("overlay-after-press"); + let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0); + h.step(); + let t = Duration::from_secs(20); + assert_eq!(h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t)["ok"], true); + assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "check:settings.confirmDelete"; + let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the checkbox is on screen"); + let before = h.app.ui.settings.confirm_delete; + let (tx, rx) = reply_channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.clickWidget".into(), serde_json::json!({"id": id}), tx).expect("the click is admitted"); + h.step(); // resolve: move, press and release are queued + h.step(); // the move is consumed + h.step(); // the press is consumed: the pointer is now down + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the press reached egui"); + // Let the prompt complete its sizing pass while the release waits, as it does when it opens + // between two host frames. A prompt now cancels the gesture at its appearance, before the + // release is delivered; the release must still end the held press without a click. + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.app.synthetic_in_flight = true; + h.step(); + assert!(!h.app.synthetic_in_flight, "the prompt's pass completed"); + assert!(h.app.widgets.iter().any(|(widget, _)| widget == "button:quitCancel"), "the prompt is laid out"); + h.step(); // the release is refused; the pointer cleanup releases the held press + let reply = rx.try_recv().expect("the canceled click answers"); + assert_eq!(reply["ok"], false, "{reply}"); + let error = reply["error"].as_str().unwrap_or_default(); + assert!(error.contains("modal prompt appeared"), "{reply}"); + assert!(error.contains("before its input was delivered"), "{reply}"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the canceled press was released"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the covered checkbox did not change"); + assert_eq!( + h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect), + Some(target), + "the target stayed where it was; nothing was retargeted" + ); + assert!(h.app.quit_prompt.is_some(), "the prompt was not activated"); + // Remove the overlay: the same target is clickable again (a positive control after refusal). + h.app.quit_prompt = None; + assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles after the overlay"); + let r = h.request("ui.clickWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.settings.confirm_delete, !before, "the unobstructed click reached the checkbox"); +} + +/// A canceled click on a drag-sensing control must not keep the pointer down or move it. The old +/// cleanup moved 8 px to suppress the click, which could start a drag and change a slider/curve. +#[test] +fn canceling_a_pressed_slider_releases_it_without_dragging_it() { + let files = SyntheticLibrary::new("canceled-pressed-slider"); + let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0); + let photo = h.app.session.visible_cloned()[0]; + let t = Duration::from_secs(20); + assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true); + assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true); + h.app.ui.open_sections = vec!["light".to_string()]; + assert!(h.settle(Duration::from_secs(60)), "the edit panel settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "slider:light.exposure"; + let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the slider is on screen"); + let before = h.app.session.develop_of(photo).expect("its develop settings").light.exposure; + let (tx, rx) = reply_channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.clickWidget".into(), serde_json::json!({"id": id}), tx).expect("the click is admitted"); + h.step(); // resolve: move, press and release are queued + h.step(); // the move is consumed + h.step(); // the press is consumed: the pointer is now down + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the press reached egui"); + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.app.synthetic_in_flight = true; + h.step(); // the prompt's pass completes while the release waits + assert!(!h.app.synthetic_in_flight); + h.step(); // the release is refused; cleanup resets the pointer before the next pass + let reply = rx.try_recv().expect("the canceled click answers"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("modal prompt appeared"), "{reply}"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the canceled press was released without a move"); + assert_eq!(h.app.session.develop_of(photo).expect("its develop settings").light.exposure, before, "cleanup did not drag the slider"); + assert_eq!( + h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect), + Some(target), + "the target stayed where it was; nothing was retargeted" + ); + assert!(h.app.quit_prompt.is_some(), "the prompt was not activated"); + assert!(h.app.pending_gestures.is_empty()); + assert!(h.app.synthetic.is_empty()); +} + +/// A non-prompt `Foreground` window that appears after the press (the launch list / library error) +/// makes the target invisible too: the release is refused rather than clicking through it. +#[test] +fn a_nonprompt_overlay_that_appears_after_the_press_cancels_the_release() { + let files = SyntheticLibrary::new("nonprompt-overlay-after-press"); + let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0); + h.step(); + let t = Duration::from_secs(20); + assert_eq!(h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t)["ok"], true); + assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "check:settings.confirmDelete"; + let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the checkbox is on screen"); + let before = h.app.ui.settings.confirm_delete; + let (tx, rx) = reply_channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.clickWidget".into(), serde_json::json!({"id": id}), tx).expect("the click is admitted"); + h.step(); // resolve: move, press and release are queued + h.step(); // the move is consumed + h.step(); // the press is consumed: the pointer is now down + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the press reached egui"); + // Draw the non-prompt overlay while the release waits. Its creation barrier cancels the + // queued release before the later stale-target visibility check can deliver it. + h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new("/no/such/library", "test error")); + h.app.synthetic_in_flight = true; + h.step(); + assert!(!h.app.synthetic_in_flight, "the overlay's pass completed"); + assert!(h.app.widgets.iter().any(|(widget, _)| widget == "button:libraryRetry"), "the library window is laid out"); + h.step(); + let reply = rx.try_recv().expect("the canceled click answers"); + assert_eq!(reply["ok"], false, "{reply}"); + let error = reply["error"].as_str().unwrap_or_default(); + assert!(error.contains("ui.clickWidget canceled: a blocking library window appeared"), "{reply}"); + assert!(error.contains("before its input was delivered"), "{reply}"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the canceled press was released"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the covered checkbox did not change"); + assert_eq!( + h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect), + Some(target), + "the target stayed where it was; nothing was retargeted" + ); + assert!(h.app.library_problem.is_some(), "the overlay was not activated"); + h.app.library_problem = None; + assert!(h.settle(Duration::from_secs(60)), "Settings settles after the overlay"); + let r = h.request("ui.clickWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.settings.confirm_delete, !before, "the unobstructed click reached the checkbox"); +} + +/// The library-problem dim is above a `Middle` Settings dialog: a control-channel click on the +/// covered checkbox is refused instead of being delivered through the dim. This is the direct +/// `ui.clickWidget` counterpart of the press/release cancellation above. +#[test] +fn a_library_problem_overlay_blocks_a_control_click_in_settings() { + let files = SyntheticLibrary::new("library-overlay-settings-click"); + let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + assert!(h.settle(Duration::from_secs(60)), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "check:settings.confirmDelete"; + let before = h.app.ui.settings.confirm_delete; + h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new("/no/such/library", "test error")); + assert!(h.settle(Duration::from_secs(60)), "the library problem window settles"); + assert!(h.app.widgets.iter().any(|(widget, _)| widget == "button:libraryRetry"), "the library window is laid out"); + let r = h.request("ui.clickWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], false, "{r}"); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains(id), "{r}"); + assert!(error.contains("no longer visible"), "{r}"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the covered checkbox did not change"); + assert!(h.app.library_problem.is_some(), "the overlay was not activated"); + // Positive control: the same control works once the covering window is gone. + h.app.library_problem = None; + assert!(h.settle(Duration::from_secs(60)), "Settings settles after the overlay"); + let r = h.request("ui.clickWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.settings.confirm_delete, !before, "the unobstructed click reached the checkbox"); +} + +/// A quit prompt over Settings owns the pointer even when Settings asks for outside clicks: the +/// raw backdrop handler must not close the dialog it cannot see. Dismissing the prompt with its +/// own button keeps Settings and does not replay the backdrop press into it. +#[test] +fn a_quit_prompt_over_settings_blocks_the_outside_click_and_keeps_the_dialog() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Settings is open"); + h.app.ui.settings.close_dialogs_outside = true; + let confirm_delete = h.app.ui.settings.confirm_delete; + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + assert!(h.app.widgets.iter().any(|(id, _)| id == "button:quitCancel"), "the prompt is laid out"); + // The prompt's first completed frame is already the real, interactable layer: no sizing pass + // (which egui makes invisible and non-interactable) may leak a release into Settings below. + let cancel = h.app.widgets.iter().find(|(id, _)| id == "button:quitCancel").map(|(_, r)| r.center()).expect("the Cancel rect"); + assert!(h.view.ctx.layer_id_at(cancel) == crate::panels::notices::prompt_layer(&h.app), "the prompt owns its first settled button click"); + // Outside both the prompt and Settings, with the outside-click preference on. + let at = egui::pos2(10.0, 890.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(at), press(true), press(false)]); + h.step(); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "the backdrop click did not close Settings"); + assert!(h.app.quit_prompt.is_some(), "the backdrop click did not dismiss the prompt"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "the raw click did not reach a Settings control"); + // A backdrop press raises its area; the prompt must stay above it, and its Cancel must work. + let r = h.request("ui.clickWidget", serde_json::json!({"id": "button:quitCancel"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.quit_prompt.is_none(), "Cancel dismissed the prompt"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "dismissing the prompt kept Settings"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "the prompt click did not reach Settings"); +} + +/// Escape and Return are the prompt's safe Cancel, not Settings' dismissal/confirmation. The +/// dialog and its Settings stay exactly as they were, then keep their normal Escape afterwards. +#[test] +fn a_quit_prompt_takes_safe_keyboard_cancel_without_closing_settings() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Settings is open"); + let confirm_delete = h.app.ui.settings.confirm_delete; + for key in ["Escape", "Enter"] { + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + let r = h.request("ui.key", serde_json::json!({"key": key}), t); + assert_eq!(r["ok"], true, "{key}: {r}"); + assert!(h.app.quit_prompt.is_none(), "{key} safely dismissed the prompt"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "{key} did not close Settings behind the prompt"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "{key} did not change Settings"); + } + // Positive control: the dialog's own Escape still works once no prompt owns it. + let r = h.request("ui.key", serde_json::json!({"key": "Escape"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "Escape closes Settings without a prompt"); +} + +/// The prompt's buttons are reachable with Tab/Shift+Tab and activate with Space. Focus is real +/// egui focus on the actual button responses, not a separate keyboard-only path. +#[test] +fn prompt_tab_shift_tab_and_space_reach_cancel() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + let confirm_delete = h.app.ui.settings.confirm_delete; + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + let cancel_id = + h.app.widget_hits.iter().rev().find(|(id, _)| id == "button:quitCancel").map(|(_, hit)| hit.id).expect("Cancel has a real response"); + let anyway_id = + h.app.widget_hits.iter().rev().find(|(id, _)| id == "button:quitAnyway").map(|(_, hit)| hit.id).expect("Quit Anyway has a real response"); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(cancel_id), "the safe Cancel starts focused"); + assert_eq!(h.request("ui.key", serde_json::json!({"key": "Tab"}), t)["ok"], true); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(anyway_id), "Tab moved to Quit Anyway"); + assert!(h.app.quit_prompt.is_some(), "Tab did not activate anything"); + assert_eq!(h.request("ui.key", serde_json::json!({"key": "Tab", "shift": true}), t)["ok"], true); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(cancel_id), "Shift+Tab moved back to Cancel"); + assert_eq!(h.request("ui.key", serde_json::json!({"key": "Space"}), t)["ok"], true); + assert!(h.app.quit_prompt.is_none(), "Space activated the focused safe Cancel"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Cancel kept Settings"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "the prompt key did not reach Settings"); +} + +/// Raw screen coordinates at a settled prompt button still work. Pointer state is hidden from the +/// background during each pass, but the real press is preserved for the next frame's release. +#[test] +fn raw_pointer_at_a_settled_prompt_button_activates_it() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + let confirm_delete = h.app.ui.settings.confirm_delete; + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + let cancel = h.app.widgets.iter().rev().find(|(id, _)| id == "button:quitCancel").map(|(_, rect)| rect.center()).expect("the Cancel rect"); + h.events.extend([egui::Event::PointerMoved(cancel), pointer_event(cancel, true)]); + h.step(); + assert!(h.app.quit_prompt.is_some(), "the press alone does not dismiss the prompt"); + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the real press survives for its release"); + h.events.push(pointer_event(cancel, false)); + h.step(); + assert!(h.app.quit_prompt.is_none(), "the release activated the settled prompt button"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Cancel kept Settings"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "the raw click did not reach Settings"); +} + +/// AccessKit Focus and Click target the prompt's actual button node. An action at a covered +/// Settings control is dropped, so it can neither focus nor activate the background. +#[test] +fn prompt_accepts_targeted_accesskit_and_rejects_background_actions() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + let confirm_delete = h.app.ui.settings.confirm_delete; + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + let cancel_id = + h.app.widget_hits.iter().rev().find(|(id, _)| id == "button:quitCancel").map(|(_, hit)| hit.id).expect("Cancel has a real response"); + let checkbox_node = h + .app + .widget_hits + .iter() + .rev() + .find(|(id, _)| id == "check:settings.confirmDelete") + .map(|(_, hit)| hit.id.accesskit_id()) + .expect("the covered checkbox has a response"); + h.events.push(accesskit_action(egui::accesskit::Action::Click, checkbox_node)); + h.step(); + assert!(h.app.quit_prompt.is_some(), "a background AccessKit Click did not dismiss the prompt"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "a background AccessKit Click did not change Settings"); + h.events.push(accesskit_action(egui::accesskit::Action::Focus, cancel_id.accesskit_id())); + h.events.push(accesskit_action(egui::accesskit::Action::Click, cancel_id.accesskit_id())); + h.step(); + assert!(h.app.quit_prompt.is_none(), "the prompt's own AccessKit node activated Cancel"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "AccessKit Cancel kept Settings"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "the AccessKit action did not reach Settings"); + // The control still answers normally after the prompt is gone. + let r = h.request("ui.clickWidget", serde_json::json!({"id": "check:settings.confirmDelete"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.settings.confirm_delete, !confirm_delete, "Settings is usable again"); +} + +/// A prompt created in the same host frame as a raw press/release must shield it. A press that +/// began before the prompt is released by the creation barrier, without becoming a background +/// click after the prompt settles. +#[test] +fn a_new_prompt_shields_raw_pointer_and_cancels_a_held_press() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + let id = "check:settings.confirmDelete"; + let at = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| rect.center()).expect("the checkbox is on screen"); + let before = h.app.ui.settings.confirm_delete; + // Press and release in the prompt's creation frame. + h.events.extend([egui::Event::PointerMoved(at), pointer_event(at, true), pointer_event(at, false)]); + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + assert!(h.app.quit_prompt.is_some(), "the raw click did not dismiss the prompt"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the creation-frame click did not reach Settings"); + // Dismiss that prompt, then start a press over Settings before a new prompt appears. + assert_eq!(h.request("ui.key", serde_json::json!({"key": "Escape"}), Duration::from_secs(20))["ok"], true); + h.step(); + h.events.extend([egui::Event::PointerMoved(at), pointer_event(at, true)]); + h.step(); + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the pre-prompt press reached egui"); + h.events.push(pointer_event(at, false)); + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the prompt creation cancelled the held press"); + assert!(h.app.quit_prompt.is_some(), "the pre-prompt release did not activate the prompt"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the press-before-prompt became a click in Settings"); +} + +/// A prompt can be created after `raw_input_hook`, when eframe's `logic` runs. The prompt barrier +/// must then drop wheel/new-touch/pointer input that egui already accepted for the pass, before +/// any panel reads it, while a touch End still clears egui's private touch state. +#[test] +fn a_prompt_first_seen_after_begin_pass_drops_wheel_touch_and_background_pointer_input() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + let at = egui::pos2(30.0, 40.0); + let mut input = raw(egui::vec2(800.0, 600.0)); + input.events = vec![ + key_event(egui::Key::A, true, egui::Modifiers::NONE), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 2.0), + phase: egui::TouchPhase::Start, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + }, + egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase: egui::TouchPhase::Start, pos: at, force: None }, + egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase: egui::TouchPhase::End, pos: at, force: None }, + egui::Event::PointerMoved(at), + pointer_event(at, true), + egui::Event::ModifiersChanged(egui::Modifiers::NONE), + ]; + let mut output = ctx.run_ui(input, |ui| { + app.quit_prompt = Some(crate::QuitPrompt::Tasks("tasks".into())); + assert!(crate::panels::notices::prepare_ui_pass(&mut app, ui.ctx()), "the prompt is first seen after begin_pass"); + let events = ui.ctx().input(|i| i.events.clone()); + assert!( + events.iter().all(|event| !matches!( + event, + egui::Event::Key { .. } + | egui::Event::Touch { phase: egui::TouchPhase::Start | egui::TouchPhase::Move, .. } + | egui::Event::MouseWheel { .. } + | egui::Event::PointerMoved(_) + | egui::Event::PointerButton { .. } + )), + "background input must not survive the prompt's creation pass: {events:?}" + ); + assert!( + events.iter().any(|event| matches!(event, egui::Event::Touch { phase: egui::TouchPhase::End, .. })), + "a touch End must still clear egui's private touch state: {events:?}" + ); + assert!(events.iter().any(|event| matches!(event, egui::Event::ModifiersChanged(_))), "modifier changes still reach egui"); + assert!(!ui.ctx().input(|i| i.pointer.any_down()), "the pointer is not left down"); + assert_eq!(ui.ctx().input(|i| i.smooth_scroll_delta), egui::Vec2::ZERO, "wheel scrolling is cleared"); + }); + output.textures_delta.clear(); +} + +/// A held host press in the same frame as a prompt created after `raw_input_hook` is released by +/// the prompt's first pass, not delivered to the covered control when the release arrives. +#[test] +fn a_prompt_created_after_raw_input_hook_shields_a_held_background_press() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + let id = "check:settings.confirmDelete"; + let at = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| rect.center()).expect("the checkbox is on screen"); + let before = h.app.ui.settings.confirm_delete; + h.events.extend([egui::Event::PointerMoved(at), pointer_event(at, true)]); + h.set_after_raw_input_hook(|app| app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into()))); + h.step(); + assert!(h.app.quit_prompt.is_some(), "the prompt is shown"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the creation pass released the held press"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the press did not reach Settings"); + h.events.push(pointer_event(at, false)); + h.step(); + assert!(h.app.quit_prompt.is_some(), "the release did not activate the prompt"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the release did not become a background click"); +} + +/// A synthetic press already delivered when `raw_input_hook` runs, then a prompt created by logic +/// before the UI pass: the whole in-flight batch was not seen by a panel and must fail, not report +/// success after the prompt filters it. +#[test] +fn a_synthetic_press_already_delivered_is_canceled_by_a_prompt_created_after_raw_input_hook() { + let files = SyntheticLibrary::new("press-after-raw-hook"); + let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + assert!( + h.settle(Duration::from_secs(60)), + "the Settings dialog settles before its target rect is captured (pending slots: {:?})", + h.app.renderer.pending_slots() + ); + let id = "check:settings.confirmDelete"; + let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the checkbox is on screen"); + let before = h.app.ui.settings.confirm_delete; + let (tx, rx) = reply_channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.clickWidget".into(), serde_json::json!({"id": id}), tx).expect("the click is admitted"); + h.step(); // resolve: move, press and release are queued + h.step(); // the move is consumed + h.step(); // the press is consumed: the pointer is now down + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the press reached egui"); + assert!(!h.app.synthetic.is_empty(), "the release is queued"); + h.set_after_raw_input_hook(|app| app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into()))); + h.step(); // the hook creates the prompt after the release was injected for this pass + let reply = rx.try_recv().expect("the canceled click answers"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("modal prompt appeared"), "{reply}"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the canceled press was released"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the filtered release did not reach Settings"); + assert_eq!( + h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect), + Some(target), + "the target stayed where it was; nothing was retargeted" + ); + assert!(h.app.quit_prompt.is_some(), "the prompt was not activated"); + assert!(h.app.pending_gestures.is_empty()); + assert!(h.app.synthetic.is_empty()); +} + +/// Raw `ui.click`, `ui.drag` and `ui.pointer` at a settled prompt are delivered to the prompt's +/// backdrop, not to the covered Settings control or image. The dialog, its control and the image +/// stay unchanged. +#[test] +fn raw_background_gestures_at_a_settled_prompt_do_not_reach_the_covered_view() { + let files = SyntheticLibrary::new("raw-gestures-settled-prompt"); + let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0); + let photo = h.app.session.visible_cloned()[0]; + let t = Duration::from_secs(20); + assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true); + assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + assert!(h.settle(Duration::from_secs(60)), "the detail view and Settings settle (pending slots: {:?})", h.app.renderer.pending_slots()); + let image = h.app.image_rect.expect("the detail image is on screen"); + let at = image.min + egui::vec2(12.0, 12.0); + let before = h.app.ui.settings.confirm_delete; + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + h.step(); + assert!(h.app.widgets.iter().any(|(widget, _)| widget == "button:quitCancel"), "the prompt is settled"); + let click = h.request("ui.click", serde_json::json!({"x": at.x, "y": at.y}), t); + assert_eq!(click["ok"], true, "{click}"); + h.step(); + let drag = h.request("ui.drag", serde_json::json!({"x": at.x, "y": at.y, "toX": at.x + 30.0, "toY": at.y + 20.0, "steps": 4}), t); + assert_eq!(drag["ok"], true, "{drag}"); + h.step(); + let pointer = h.request( + "ui.pointer", + serde_json::json!({"events": [ + {"kind": "down", "x": 0.05, "y": 0.05}, + {"kind": "move", "x": 0.08, "y": 0.08}, + {"kind": "up", "x": 0.08, "y": 0.08} + ]}), + t, + ); + assert_eq!(pointer["ok"], true, "{pointer}"); + h.step(); + assert!(h.app.quit_prompt.is_some(), "the raw background gestures did not dismiss the prompt"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the raw gestures did not reach Settings"); + assert_eq!(h.app.image_rect, Some(image), "the raw pointer gestures did not pan, zoom or drag the image"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Settings is still open"); +} + +/// Releases are not keyboard actions, but egui needs them to clear `keys_down`. A prompt must keep +/// the release (and the held key must not survive the prompt) even though it drops every press. +#[test] +fn a_prompt_keeps_key_releases_and_clears_held_keys() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + let mut raw = raw(egui::vec2(800.0, 600.0)); + raw.events.push(key_event(egui::Key::Backslash, false, egui::Modifiers::NONE)); + app.raw_input_hook(&ctx, &mut raw); + assert!( + raw.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::Backslash, pressed: false, .. })), + "the prompt's raw filter kept the key release for keys_down" + ); + // A held background key is cleared at the prompt transition, not left asserted under it. + let mut h = Headless::new(app, [800.0, 600.0], 1.0); + h.step(); + h.view.ctx.input_mut(|i| { + i.keys_down.insert(egui::Key::Backslash); + }); + h.events.push(key_event(egui::Key::Backslash, false, egui::Modifiers::NONE)); + h.step(); + assert!(!h.view.ctx.input(|i| i.key_down(egui::Key::Backslash)), "the release cleared the held key under the prompt"); +} + +/// A synthetic drag whose press already reached egui is aborted when a prompt appears: its queued +/// moves/release are discarded, the pointer is released without a click, and the slider is intact. +#[test] +fn a_prompt_aborts_an_in_progress_synthetic_drag() { + let files = SyntheticLibrary::new("prompt-aborts-drag"); + let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0); + let photo = h.app.session.visible_cloned()[0]; + let t = Duration::from_secs(20); + assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true); + assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true); + h.app.ui.open_sections = vec!["light".to_string()]; + assert!(h.settle(Duration::from_secs(60)), "the edit panel settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "slider:light.exposure"; + let before = h.app.session.develop_of(photo).expect("its develop settings").light.exposure; + let (tx, rx) = reply_channel(); + h.app + .admit_gesture(&h.view.ctx.clone(), "ui.dragWidget".into(), serde_json::json!({"id": id, "dx": 40.0, "steps": 20}), tx) + .expect("the drag is admitted"); + h.step(); // resolve: move, press and many moves are queued + h.step(); // move + h.step(); // press + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the drag press reached egui"); + assert!(!h.app.synthetic.is_empty(), "the drag still has queued moves"); + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + let reply = rx.try_recv().expect("the aborted drag answers"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("modal prompt appeared"), "{reply}"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the drag's pointer was released"); + assert_eq!(h.app.session.develop_of(photo).expect("its develop settings").light.exposure, before, "the drag did not continue under the prompt"); + assert!(h.app.synthetic.is_empty(), "the rest of the drag was dropped"); + assert!(h.app.pending_gestures.is_empty()); + assert!(h.app.quit_prompt.is_some(), "the drag did not dismiss the prompt"); +} + +/// Changing from a Tasks prompt to an Unsaved prompt, or from one settings notice to the next, +/// is a new prompt. Its creation frame must shield pointer input even though the old prompt was +/// already settled. +#[test] +fn prompt_queue_and_kind_transitions_shield_the_first_frame() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + let id = "check:settings.confirmDelete"; + let at = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| rect.center()).expect("the checkbox is on screen"); + let before = h.app.ui.settings.confirm_delete; + // A raw click resolved before the prompt exists must be refused before its first event, not + // land in the prompt's creation frame or be replayed after it goes away. + let (tx, rx) = reply_channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.click".into(), serde_json::json!({"x": at.x, "y": at.y}), tx).expect("the raw click is admitted"); + h.step(); + assert!(!h.app.synthetic.is_empty(), "the raw click is queued"); + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("tasks".into())); + h.step(); + let reply = rx.try_recv().expect("the pre-prompt raw click answers"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("modal prompt appeared"), "{reply}"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the queued raw click did not reach Settings"); + // Tasks -> Unsaved in the same host frame as a raw click on the covered checkbox. + h.events.extend([egui::Event::PointerMoved(at), pointer_event(at, true), pointer_event(at, false)]); + h.app.quit_prompt = Some(crate::QuitPrompt::Unsaved("unsaved".into())); + h.step(); + assert!(matches!(h.app.quit_prompt, Some(crate::QuitPrompt::Unsaved(_))), "the new prompt is shown"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the transition frame did not click Settings"); + // Dismiss Unsaved, then switch from one settings notice to the next in a pointer frame. + assert_eq!(h.request("ui.key", serde_json::json!({"key": "Escape"}), Duration::from_secs(20))["ok"], true); + h.app.notices = vec!["first notice".into()]; + h.step(); + h.step(); + h.events.extend([egui::Event::PointerMoved(at), pointer_event(at, true), pointer_event(at, false)]); + h.app.notices = vec!["second notice".into()]; + h.step(); + assert_eq!(h.app.notices, vec!["second notice".to_string()], "the queued notice is still shown"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "the notice transition did not click Settings"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Settings survived both transitions"); +} + +/// A prompt with no dialog must still stop global shortcuts. The rating key is a background +/// photo action, not part of the prompt. +#[test] +fn a_quit_prompt_blocks_global_rating_shortcuts() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + let photo = h.app.session.visible_cloned()[0]; + assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true); + h.step(); + // Positive control: the same key rates the photo while no prompt is up. + let r = h.request("ui.key", serde_json::json!({"key": "4"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.catalog.photo(photo).expect("the photo").rating, 4, "the rating key works without a prompt"); + let before = h.app.session.catalog.photo(photo).expect("the photo").rating; + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + let r = h.request("ui.key", serde_json::json!({"key": "5"}), t); + assert_eq!(r["ok"], false, "the prompt blocked the key, so it must not report a delivered shortcut: {r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("modal prompt"), "{r}"); + assert_eq!(h.app.session.catalog.photo(photo).expect("the photo").rating, before, "no rating behind the prompt"); + assert!(h.app.quit_prompt.is_some(), "the shortcut did not dismiss the prompt"); + assert!(h.app.pending_gestures.is_empty() && h.app.synthetic.is_empty(), "the blocked key left no queued input"); +} + +/// A settled prompt drops text and scroll input, and the control channel must report that refusal +/// instead of a false success: neither event has a prompt meaning. The prompt stays up. +#[test] +fn a_settled_prompt_rejects_blocked_text_and_scroll_gestures() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + h.app.quit_prompt = Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.step(); + for (method, params) in [("ui.text", serde_json::json!({"text": "x"})), ("ui.scroll", serde_json::json!({"dx": 0.0, "dy": 30.0}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], false, "{method}: {r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("modal prompt"), "{method}: {r}"); + assert!(h.app.quit_prompt.is_some(), "{method} did not dismiss the prompt"); + assert!(h.app.pending_gestures.is_empty() && h.app.synthetic.is_empty(), "{method} left queued input"); + } +} + +/// The settings-notice path uses the same barrier and window: it blocks the dialog below it (both +/// pointer and the outside-click preference), and its own OK dismisses it without closing Settings. +#[test] +fn a_settings_notice_blocks_background_input_and_its_ok_button_works() { + let mut h = Headless::new(app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "Settings is open"); + h.app.ui.settings.close_dialogs_outside = true; + let confirm_delete = h.app.ui.settings.confirm_delete; + h.app.notices.push("The settings file could not be read and was reset.".into()); + h.step(); + assert!(h.app.widgets.iter().any(|(id, _)| id == "button:noticeOk"), "the notice is laid out"); + assert!(h.app.widgets.iter().any(|(id, _)| id == "check:settings.confirmDelete"), "the covered checkbox is laid out"); + // A covered Settings control cannot be clicked, and a backdrop click cannot close Settings. + let r = h.request("ui.clickWidget", serde_json::json!({"id": "check:settings.confirmDelete"}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.confirm_delete, confirm_delete, "the covered checkbox did not change"); + let at = egui::pos2(10.0, 890.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(at), press(true), press(false)]); + h.step(); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "the backdrop click did not close Settings"); + assert!(!h.app.notices.is_empty(), "the backdrop click did not dismiss the notice"); + // OK dismisses only the notice; Settings survives and becomes usable again. + let r = h.request("ui.clickWidget", serde_json::json!({"id": "button:noticeOk"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.notices.is_empty(), "OK dismissed the notice"); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Settings { .. })), "dismissing the notice kept Settings"); + let r = h.request("ui.clickWidget", serde_json::json!({"id": "check:settings.confirmDelete"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.settings.confirm_delete, !confirm_delete, "the checkbox works again after the notice"); +} + +#[test] +fn clipping_changed_between_the_move_and_press_is_seen_in_the_latest_registry() { + let files = SyntheticLibrary::new("clipping-changed"); + let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0); + let photo = h.app.session.visible_cloned()[0]; + let t = Duration::from_secs(20); + assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true); + assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true); + assert!(h.settle(Duration::from_secs(60)), "the detail view settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "star:bottom:2"; + let (tx, rx) = reply_channel(); + h.app.admit_gesture(&h.view.ctx.clone(), "ui.clickWidget".into(), serde_json::json!({"id": id}), tx).expect("the click is admitted"); + h.step(); // resolve: move, press and release are queued + h.step(); // the move is consumed + // The latest completed pass clipped the widget without moving it to another layer. Only the + // clipped rect in the registry knows that; `read_response` before `begin_pass` can still see + // the pass before the registry that was just published. + let hit = &mut h.app.widget_hits.iter_mut().rev().find(|(widget, _)| widget == id).expect("the target has a probe").1; + hit.interact_rect = egui::Rect::NOTHING; + h.step(); // the press is refused instead of being delivered to the clipped target + let reply = rx.try_recv().expect("the clipped click answers"); + assert_eq!(reply["ok"], false, "{reply}"); + let error = reply["error"].as_str().unwrap_or_default(); + assert!(error.contains("no longer visible"), "{reply}"); + assert!(error.contains("before its press"), "{reply}"); + assert!(h.app.pending_gestures.is_empty()); + assert!(h.app.synthetic.is_empty()); +} + +#[test] +fn a_timeout_cancels_only_its_own_gesture_and_preserves_a_later_queue() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + app.widgets = vec![ + ("first:test".into(), egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(30.0, 20.0))), + ("second:test".into(), egui::Rect::from_min_size(egui::pos2(60.0, 10.0), egui::vec2(30.0, 20.0))), + ]; + let (tx1, rx1) = reply_channel(); + let (tx2, rx2) = reply_channel(); + app.admit_gesture(&ctx, "ui.clickWidget".into(), serde_json::json!({"id": "first:test"}), tx1).expect("the first click is admitted"); + app.admit_gesture(&ctx, "ui.clickWidget".into(), serde_json::json!({"id": "second:test"}), tx2).expect("the second click is admitted"); + app.layout_epoch = app.layout_epoch.wrapping_add(1); + app.resolve_pending_gestures(&ctx); + assert_eq!(app.synthetic.len(), 6, "move, press and release for each click"); + // The first gesture stalls after its events were queued; the second was resolved behind it and + // has its own deadline. A timeout must remove only the first gesture's unsent prefix. + app.pending_gestures[0].deadline_ms = 0.0; + app.reply_delivered_gestures(); + let reply1 = rx1.try_recv().expect("the stalled first gesture answers"); + assert_eq!(reply1["ok"], false, "{reply1}"); + assert!(reply1["error"].as_str().unwrap_or_default().contains("timed out before the UI consumed its last event"), "{reply1}"); + assert_eq!(app.synthetic.len(), 3, "the later gesture's events are preserved"); + assert!(rx2.try_recv().is_err(), "the later gesture is still waiting"); + assert_eq!(app.pending_gestures.len(), 1); + assert_eq!(app.pending_gestures[0].target.as_ref().map(|target| target.id.as_str()), Some("second:test")); +} + +#[test] +fn queued_pointer_cleanup_requests_a_repaint_without_a_synthetic_event() { + let mut app = app(); + app.synthetic.clear(); + app.pointer_cleanup = true; + assert!(app.synthetic.is_empty()); + assert!(app.queued_input_needs_repaint(), "the pending cleanup alone must ask for the frame that injects it"); + // A timeout after the last synthetic batch is drained can queue cleanup inside finish_ui_pass; + // that pass itself has to ask for the frame that delivers it. + let ctx = egui::Context::default(); + app.finish_ui_pass(&ctx); + assert!(ctx.has_requested_repaint(), "finish_ui_pass requested the cleanup frame"); + app.pointer_cleanup = false; + assert!(!app.queued_input_needs_repaint()); +} + +#[test] +fn a_moved_widget_cancels_before_its_first_event_and_leaves_a_later_gesture_queued() { + let files = SyntheticLibrary::new("moved-widget-queued"); + let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0); + h.step(); + let t = Duration::from_secs(20); + let r = h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots()); + assert!(h.app.widgets.iter().any(|(id, _)| id == "check:settings.confirmDelete")); + assert!(h.app.widgets.iter().any(|(id, _)| id == "button:settingsStartup-2")); + + let (tx1, rx1) = reply_channel(); + let (tx2, rx2) = reply_channel(); + let ctx = h.view.ctx.clone(); + h.app + .admit_gesture(&ctx, "ui.clickWidget".into(), serde_json::json!({"id": "check:settings.confirmDelete"}), tx1) + .expect("the first click is admitted"); + h.app + .admit_gesture(&ctx, "ui.clickWidget".into(), serde_json::json!({"id": "button:settingsStartup-2"}), tx2) + .expect("the second click is admitted"); + // This completed pass resolves both against the same registry and queues move, press, release + // for each. + h.step(); + assert!(rx1.try_recv().is_err(), "nothing is answered at resolution"); + assert!(rx2.try_recv().is_err(), "nothing is answered at resolution"); + assert_eq!(h.app.synthetic.len(), 6, "both gestures are queued"); + + // Before the first event the first target's latest rect moves (the second target stays put). + let moved = egui::Rect::from_min_size(egui::pos2(900.0, 700.0), egui::vec2(10.0, 10.0)); + let entry = h.app.widgets.iter_mut().rev().find(|entry| entry.0 == "check:settings.confirmDelete").expect("the first target is registered"); + entry.1 = moved; + // The first event's injection now cancels only the head; the second gesture's three events must + // stay at the front of the queue. + h.step(); + let reply1 = rx1.try_recv().expect("the canceled first gesture answers"); + assert_eq!(reply1["ok"], false, "{reply1}"); + let error = reply1["error"].as_str().unwrap_or_default(); + assert!(error.contains("before its first event"), "{reply1}"); + assert!(error.contains("nothing was retargeted"), "{reply1}"); + assert_eq!(h.app.synthetic.len(), 3, "the later gesture's events are preserved"); + assert!(rx2.try_recv().is_err(), "the later gesture is still waiting"); + + for _ in 0..8 { + h.step(); + } + let reply2 = rx2.try_recv().expect("the later gesture is delivered"); + assert_eq!(reply2["ok"], true, "{reply2}"); + assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail, "the second click ran"); + assert!(h.app.pending_gestures.is_empty()); +} + +#[test] +fn a_click_whose_target_vanishes_after_the_press_fails_and_releases_the_pointer() { + let files = SyntheticLibrary::new("target-vanishes"); + let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0); + h.step(); + let t = Duration::from_secs(20); + let r = h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let before = h.app.ui.settings.confirm_delete; + + let (tx, rx) = reply_channel(); + h.app + .admit_gesture(&h.view.ctx.clone(), "ui.clickWidget".into(), serde_json::json!({"id": "check:settings.confirmDelete"}), tx) + .expect("the click is admitted"); + h.step(); // resolve: move, press, release are queued + h.step(); // the move is consumed + h.step(); // the press is consumed: the pointer is now down + assert!(h.view.ctx.input(|i| i.pointer.any_down()), "the press reached egui"); + + // The widget vanishes between press and release (the click's own effect or a layout change). + h.app.widgets.retain(|(id, _)| id != "check:settings.confirmDelete"); + h.step(); // the release is refused, and a safe release is injected for the down pointer + let reply = rx.try_recv().expect("the canceled click answers"); + assert_eq!(reply["ok"], false, "{reply}"); + let error = reply["error"].as_str().unwrap_or_default(); + assert!(error.contains("disappeared before its release"), "{reply}"); + assert!(error.contains("nothing was retargeted"), "{reply}"); + assert!(!h.view.ctx.input(|i| i.pointer.any_down()), "the canceled press was released"); + assert_eq!(h.app.ui.settings.confirm_delete, before, "no click was delivered"); + assert!(h.app.pending_gestures.is_empty()); + assert!(h.app.synthetic.is_empty()); +} + +#[test] +fn a_discarded_layout_does_not_publish_its_widget_registry() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(800.0, 600.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + let kept = egui::Rect::from_min_size(egui::pos2(1.0, 2.0), egui::vec2(3.0, 4.0)); + let discarded = egui::Rect::from_min_size(egui::pos2(5.0, 6.0), egui::vec2(7.0, 8.0)); + app.widgets = vec![("kept:test".into(), kept)]; + app.next_widgets = vec![("discarded:test".into(), discarded)]; + let mut passes = 0; + let mut output = ctx.run_pass(raw(egui::vec2(800.0, 600.0)), |ctx| { + if passes == 0 { + ctx.request_discard("test: throw this layout away"); + app.finish_ui_pass(ctx); + } + passes += 1; + }); + output.textures_delta.clear(); + assert!(passes > 1, "egui reran the discarded layout"); + assert_eq!(app.widgets, vec![("kept:test".into(), kept)], "the discarded registry was not published"); + assert!(app.next_widgets.is_empty()); +} + +#[test] +fn a_gesture_replies_only_after_the_completed_pass_consumed_its_event() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + app.ui.view = crate::state::ViewMode::Detail; + let (tx, rx) = reply_channel(); + app.admit_gesture(&ctx, "ui.move".into(), serde_json::json!({"x": 12.0, "y": 14.0}), tx).expect("admitted"); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + assert_eq!(app.synthetic.len(), 1, "resolution queued the event"); + assert!(rx.try_recv().is_err(), "not answered before the UI consumed it"); + let mut next = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut next); + assert!(next.events.iter().any(|e| matches!(e, egui::Event::PointerMoved(_))), "the event is injected next frame"); + frame(&ctx, next, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + let reply = rx.try_recv().expect("the delivery reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(app.pending_gestures.is_empty()); +} + +#[test] +fn a_delivered_gesture_may_change_the_state_it_acted_on() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + let (tx, rx) = reply_channel(); + app.admit_gesture(&ctx, "ui.key".into(), serde_json::json!({"key": "j", "cmd": true}), tx).expect("admitted"); + // The first completed pass resolves the key against the Detail layout and queues it. + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + assert!(!app.synthetic.is_empty(), "the key is queued"); + // The pass that consumes it opens the dialog: that is the gesture doing its job, not drift. + let mut next = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut next); + frame(&ctx, next, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + assert_eq!(app.ui.dialog, Some(crate::state::Dialog::ViewOptions)); + let reply = rx.try_recv().expect("the delivered gesture answers"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(app.pending_gestures.is_empty()); +} + +#[test] +fn a_gesture_cancels_when_the_view_changes_before_its_batch_is_injected() { + let ctx = egui::Context::default(); + let mut app = app(); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| app.logic(ui.ctx())); + app.logic(&ctx); + assert!(app.fonts_ready); + let (tx, rx) = reply_channel(); + app.admit_gesture(&ctx, "ui.move".into(), serde_json::json!({"x": 10.0, "y": 10.0}), tx).expect("admitted"); + frame(&ctx, raw(egui::vec2(1000.0, 700.0)), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + assert_eq!(app.synthetic.len(), 1, "resolution queued the event"); + // A hidden logic tick changes what the queued pointer event would mean before a visible + // pass consumes it: fail closed instead of injecting it into the new view. + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut next = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut next); + assert!(next.events.is_empty(), "no stale event reaches the new view"); + assert!(app.synthetic.is_empty()); + let reply = rx.try_recv().expect("the cancel error answers once"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("canceled"), "{reply}"); + assert!(app.pending_gestures.is_empty()); +} + +fn queued_shot(sent_at_ms: Option) -> PendingShot { + let (tx, _rx) = reply_channel(); + PendingShot { + token: 1, + target: None, + reply: tx, + headless: false, + admitted_passes: 0, + not_before_ms: 0.0, + force_ms: 0.0, + deadline_ms: 10_000.0, + sent_at_ms, + reserved: 1, + slot: None, + issued: None, + issued_library: 0, + } +} + +#[test] +fn hidden_paint_requests_only_queued_shots_not_issued_ones() { + let mut app = app(); + app.pending_screenshots.push(queued_shot(Some(1.0))); + assert!(!app.needs_ui_pass_while_hidden(), "an issued screenshot forces its own paint"); + app.pending_screenshots.clear(); + app.pending_screenshots.push(queued_shot(None)); + assert!(app.needs_ui_pass_while_hidden(), "a queued/settling screenshot needs a UI pass"); + app.pending_screenshots.clear(); + app.synthetic.push(egui::Event::PointerMoved(egui::Pos2::ZERO)); + assert!(app.needs_ui_pass_while_hidden(), "a gesture step needs a UI pass"); +} + +#[test] +fn disclosure_rows_publish_expanded_not_toggled() { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut seen = Vec::new(); + frame(&ctx, raw(egui::vec2(400.0, 300.0)), |ui| { + let (resp, _) = crate::widgets::section_header(ui, "test-section", "Light", true, None); + seen.push(ui.ctx().accesskit_node_builder(resp.id, |node| node.is_expanded()).flatten()); + }); + frame(&ctx, raw(egui::vec2(400.0, 300.0)), |ui| { + let (resp, _) = crate::widgets::section_header(ui, "test-section", "Light", false, None); + seen.push(ui.ctx().accesskit_node_builder(resp.id, |node| node.is_expanded()).flatten()); + }); + assert_eq!(seen, vec![Some(true), Some(false)], "the disclosure reports Expanded, not Toggled"); +} + +/// `ui.hoverWidget` obeys the same clipped/covered-target contract as a press: a hover whose +/// widget was clipped before delivery is refused, as is one under the non-modal library window. +#[test] +fn hover_widget_refuses_clipped_and_covered_targets() { + let files = SyntheticLibrary::new("hover-refuses-clipped"); + let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0); + let t = Duration::from_secs(20); + h.step(); + assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens"); + assert!(h.settle(Duration::from_secs(60)), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots()); + let id = "check:settings.confirmDelete"; + assert!(h.app.widget_hits.iter().any(|(widget, _)| widget == id), "{id} has a probe"); + + let (tx, rx) = reply_channel(); + let ctx = h.view.ctx.clone(); + h.app.admit_gesture(&ctx, "ui.hoverWidget".into(), serde_json::json!({"id": id}), tx).expect("the hover is admitted"); + h.step(); // resolve: the pointer move is queued, not consumed + assert!(rx.try_recv().is_err(), "nothing is answered at resolution"); + // The latest completed registry says the widget has no clipped interaction area. The hover + // must fail before its first pointer move, like a press at the same stale point. + if let Some(hit) = h.app.widget_hits.iter_mut().rev().find(|(widget, _)| widget == id) { + hit.1.interact_rect = egui::Rect::NOTHING; + } + h.step(); + let reply = rx.try_recv().expect("the clipped hover answers"); + assert_eq!(reply["ok"], false, "{reply}"); + let error = reply["error"].as_str().unwrap_or_default(); + assert!(error.contains("no longer visible"), "{reply}"); + assert!(error.contains("before its first event"), "{reply}"); + assert!(h.app.pending_gestures.is_empty() && h.app.synthetic.is_empty()); + + // Positive control: the next pass publishes the real clipped rect again. + let r = h.request("ui.hoverWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], true, "an unobstructed hover still works: {r}"); + + // The library window is a non-modal overlay (its dim is the modal layer). A target below it + // must be refused before a pointer move is queued, then work again after the window closes. + h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new("/no/such/library", "test error")); + assert!(h.settle(Duration::from_secs(60)), "the library window settles"); + assert!(h.app.widgets.iter().any(|(widget, _)| widget == "button:libraryRetry"), "the library window is laid out"); + let r = h.request("ui.hoverWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains(id), "{r}"); + h.app.library_problem = None; + assert!(h.settle(Duration::from_secs(60)), "Settings settles after the overlay"); + let r = h.request("ui.hoverWidget", serde_json::json!({"id": id}), t); + assert_eq!(r["ok"], true, "the same hover works once the overlay is gone: {r}"); +} + +/// A system paste request is a viewport command, not a synthetic event, so the prompt/library +/// barrier cannot filter it later. It must be refused while either newly-created barrier owns the +/// pass, and still work normally without one. +#[test] +fn a_system_paste_under_a_new_barrier_is_refused() { + let mut h = Headless::new(app(), [1200.0, 760.0], 1.0); + let t = Duration::from_secs(20); + h.settle(Duration::from_secs(60)); + let r = h.request("ui.clickWidget", serde_json::json!({"id": "field:search"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.view.clipboard = "blocked".into(); + let ctx = h.view.ctx.clone(); + + for (barrier, message) in [ + (Some(crate::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())), "modal prompt"), + (None, "blocking library window"), + ] { + if barrier.is_some() { + h.app.quit_prompt = barrier; + } else { + h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new("/no/such/library", "test error")); + } + let (tx, rx) = reply_channel(); + h.app.admit_gesture(&ctx, "ui.clipboard".into(), serde_json::json!({"action": "paste"}), tx).expect("the paste is admitted"); + h.app.layout_epoch = h.app.layout_epoch.wrapping_add(1); + h.app.resolve_pending_gestures(&ctx); + let reply = rx.try_recv().expect("the blocked paste answers"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains(message), "{reply}"); + assert!(h.app.pending_gestures.is_empty() && h.app.synthetic.is_empty()); + h.app.quit_prompt = None; + h.app.library_problem = None; + } + + // Positive control: with no barrier, the host clipboard answers and the focused field changes. + h.step(); + h.view.clipboard = "allowed".into(); + let r = h.request("ui.clipboard", serde_json::json!({"action": "paste"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.search, "allowed", "an ordinary system paste still reaches the focused field"); +} + +/// A canceled synthetic press must not overwrite a real host modifier change that arrived in the +/// same frame, later cleanup must not clear that still-held host modifier, and a host release must +/// not leave stale synthetic ownership that suppresses the next synthetic Shift gesture. +#[test] +fn synthetic_modifier_ownership_follows_host_changes_across_frames() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + // The synthetic gesture held Alt; its cleanup is in flight when the real host presses Shift. + app.synthetic_mods = egui::Modifiers::ALT; + app.synthetic_mods_release = true; + app.pointer_cleanup = true; + let mut input = raw(egui::vec2(1000.0, 700.0)); + input.events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + app.raw_input_hook(&ctx, &mut input); + assert_eq!(input.events, vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)], "the host's Shift press survives cleanup"); + assert_eq!(app.host_mods, egui::Modifiers::SHIFT, "the host state is tracked separately"); + assert_eq!(app.synthetic_mods, egui::Modifiers::NONE, "the canceled gesture no longer owns modifiers"); + assert!(!app.synthetic_mods_release); + + // A later synthetic cleanup with no host event must restore the still-held Shift, not NONE. + app.synthetic_mods = egui::Modifiers::ALT; + app.synthetic_mods_release = true; + app.pointer_cleanup = true; + let mut input = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut input); + assert_eq!( + input.events, + vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)], + "cleanup restored the physical Shift rather than clearing it" + ); + assert_eq!(app.host_mods, egui::Modifiers::SHIFT); + assert_eq!(app.synthetic_mods, egui::Modifiers::NONE); + + // A plain synthetic event carrying NONE likewise must not clear the host's Shift. + app.synthetic.push(egui::Event::Text("plain".into())); + let mut input = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut input); + assert!( + !input.events.iter().any(|e| matches!(e, egui::Event::ModifiersChanged(_))), + "a NONE synthetic event did not announce NONE: {:?}", + input.events + ); + assert!(input.events.iter().any(|e| matches!(e, egui::Event::Text(text) if text == "plain"))); + app.synthetic_in_flight = false; + + // The host releases Shift. Any stale synthetic ownership would now suppress a later gesture. + let mut input = raw(egui::vec2(1000.0, 700.0)); + input.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + app.raw_input_hook(&ctx, &mut input); + assert_eq!(app.host_mods, egui::Modifiers::NONE); + assert_eq!(app.synthetic_mods, egui::Modifiers::NONE); + + // The next synthetic gesture that carries Shift must announce it. + app.synthetic.push(key_event(egui::Key::A, true, egui::Modifiers::SHIFT)); + app.synthetic.push(key_event(egui::Key::A, false, egui::Modifiers::SHIFT)); + let mut input = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut input); + assert!( + input.events.iter().any(|e| matches!(e, egui::Event::ModifiersChanged(m) if *m == egui::Modifiers::SHIFT)), + "the fresh synthetic Shift gesture announces its modifier: {:?}", + input.events + ); + assert_eq!(app.synthetic_mods, egui::Modifiers::SHIFT); + app.synthetic_in_flight = false; + + // Its release returns to the host's state (NONE), not a stale synthetic Shift. + let mut input = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut input); + assert_eq!(input.events, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(app.synthetic_mods, egui::Modifiers::NONE); + + // Positive control: with no host change, cleanup still releases synthetic modifiers to the + // host's last known (NONE) state. + app.synthetic_mods = egui::Modifiers::ALT; + app.synthetic_mods_release = true; + app.pointer_cleanup = true; + let mut input = raw(egui::vec2(1000.0, 700.0)); + app.raw_input_hook(&ctx, &mut input); + assert_eq!(input.events, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(app.synthetic_mods, egui::Modifiers::NONE); + assert!(!app.synthetic_mods_release); +} diff --git a/crates/ui-egui/src/tests_lightroom_destination.rs b/crates/ui-egui/src/tests/lightroom_destination.rs similarity index 100% rename from crates/ui-egui/src/tests_lightroom_destination.rs rename to crates/ui-egui/src/tests/lightroom_destination.rs diff --git a/crates/ui-egui/src/tests_lights_out.rs b/crates/ui-egui/src/tests/lights_out.rs similarity index 100% rename from crates/ui-egui/src/tests_lights_out.rs rename to crates/ui-egui/src/tests/lights_out.rs diff --git a/crates/ui-egui/src/tests_mask_effects.rs b/crates/ui-egui/src/tests/mask_effects.rs similarity index 100% rename from crates/ui-egui/src/tests_mask_effects.rs rename to crates/ui-egui/src/tests/mask_effects.rs diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests/masking.rs similarity index 100% rename from crates/ui-egui/src/tests_masking.rs rename to crates/ui-egui/src/tests/masking.rs diff --git a/crates/ui-egui/src/tests_masking_layout.rs b/crates/ui-egui/src/tests/masking_layout.rs similarity index 100% rename from crates/ui-egui/src/tests_masking_layout.rs rename to crates/ui-egui/src/tests/masking_layout.rs diff --git a/crates/ui-egui/src/tests_menubar.rs b/crates/ui-egui/src/tests/menubar.rs similarity index 87% rename from crates/ui-egui/src/tests_menubar.rs rename to crates/ui-egui/src/tests/menubar.rs index 758ac595a..a67796de8 100644 --- a/crates/ui-egui/src/tests_menubar.rs +++ b/crates/ui-egui/src/tests/menubar.rs @@ -18,7 +18,14 @@ fn app(size: [f32; 2]) -> Headless { } fn widget(h: &Headless, id: &str) -> Option { - h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +/// The scroll viewport clips a menu row independently of the popup's outer rectangle, which +/// also contains its scroll arrows. Target the portion a real pointer can reach. +fn visible_rect(h: &Headless, id: &str) -> Option { + let hit = h.app.widget_hits.iter().rev().find(|(w, _)| w == id)?.1; + Some(h.view.ctx.layer_transform_to_global(hit.layer_id).map_or(hit.interact_rect, |t| t * hit.interact_rect)) } fn widgets_with(h: &Headless, prefix: &str) -> Vec<(String, egui::Rect)> { @@ -44,6 +51,7 @@ fn no_menu_or_submenu_covers_the_menu_bar() { // scrolled menu's submenus must open upward. let sizes = [[1280.0, 703.0], [1160.0, 560.0]]; let mut submenus = 0; + let mut clipped_rows = 0; for size in sizes { for top in ["Photo", "File", "View"] { let what = format!("{size:?} {top}"); @@ -57,7 +65,16 @@ fn no_menu_or_submenu_covers_the_menu_bar() { let level = widget(&h, "menu-level:1").unwrap_or_else(|| panic!("{what}: menu")); let rows: Vec = widgets_with(&h, "menusub:").into_iter().filter(|(_, r)| level.contains_rect(*r)).map(|(id, _)| id).collect(); for row in rows { - let r = h.request("ui.hoverWidget", json!({"id": row}), T); + let full = widget(&h, &row).unwrap_or_else(|| panic!("{what}: no {row}")); + let visible = visible_rect(&h, &row).unwrap_or_else(|| panic!("{what}: no hit area for {row}")); + assert!(full.is_positive() && visible.is_positive(), "{what}: {row} has no visible portion"); + let at = visible.center(); + if !visible.contains(full.center()) { + clipped_rows += 1; + } + let fx = (at.x - full.left()) / full.width(); + let fy = (at.y - full.top()) / full.height(); + let r = h.request("ui.hoverWidget", json!({"id": row, "fx": fx, "fy": fy}), T); assert_eq!(r["ok"], true, "{what}: {r}"); h.settle(SETTLE); if widget(&h, "menu-level:2").is_none() { @@ -69,6 +86,7 @@ fn no_menu_or_submenu_covers_the_menu_bar() { } } assert!(submenus >= 6, "opened {submenus} submenus"); + assert!(clipped_rows > 0, "exercise a row whose geometric centre is clipped away"); } #[test] diff --git a/crates/ui-egui/src/tests_merge_preview.rs b/crates/ui-egui/src/tests/merge_preview.rs similarity index 100% rename from crates/ui-egui/src/tests_merge_preview.rs rename to crates/ui-egui/src/tests/merge_preview.rs diff --git a/crates/ui-egui/src/tests_model_setup.rs b/crates/ui-egui/src/tests/model_setup.rs similarity index 99% rename from crates/ui-egui/src/tests_model_setup.rs rename to crates/ui-egui/src/tests/model_setup.rs index 1366b745a..22c4ca7f9 100644 --- a/crates/ui-egui/src/tests_model_setup.rs +++ b/crates/ui-egui/src/tests/model_setup.rs @@ -90,7 +90,7 @@ fn amount(app: &LightcraftApp, id: PhotoId) -> f64 { app.session.develop_of(id).unwrap().enhance.denoise } fn resume(app: &mut LightcraftApp) { - model_setup::pump(app, &egui::Context::default()); + model_setup::pump(app, &egui::Context::default(), app.tick_now()); } #[test] diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests/offline.rs similarity index 100% rename from crates/ui-egui/src/tests_offline.rs rename to crates/ui-egui/src/tests/offline.rs diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests/open_lightroom.rs similarity index 98% rename from crates/ui-egui/src/tests_open_lightroom.rs rename to crates/ui-egui/src/tests/open_lightroom.rs index 64de477fe..d97f9d0e8 100644 --- a/crates/ui-egui/src/tests_open_lightroom.rs +++ b/crates/ui-egui/src/tests/open_lightroom.rs @@ -47,6 +47,7 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { pick_folder: Some(Box::new(move || Some(picked.clone()))), png: Some(Box::new(png)), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + write_new: Some(Box::new(lightcraft_engine::export::write_file_new)), ..Default::default() }; let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests/panels.rs similarity index 99% rename from crates/ui-egui/src/tests_panels.rs rename to crates/ui-egui/src/tests/panels.rs index 96b0a1a9d..569a12910 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests/panels.rs @@ -2206,7 +2206,13 @@ fn recognise_faces_stays_off_until_a_model_is_chosen() { let dir = std::env::temp_dir().join(format!("lc-ui-faces-toggle-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); + // This tests the recognition toggle, not GPU attachment. Seed the fresh fixture before + // loading a model; the mutation command also detaches the process-wide detector. + let models = dir.join("models"); + std::fs::create_dir_all(&models).unwrap(); + std::fs::write(models.join("settings.json"), br#"{"cpuOnly":true}"#).unwrap(); + h.app.session.face_models_dir = Some(models); + assert_eq!(h.app.session.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], true); let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "faces"}}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); diff --git a/crates/ui-egui/src/tests_preset_favorites.rs b/crates/ui-egui/src/tests/preset_favorites.rs similarity index 100% rename from crates/ui-egui/src/tests_preset_favorites.rs rename to crates/ui-egui/src/tests/preset_favorites.rs diff --git a/crates/ui-egui/src/tests_preset_import.rs b/crates/ui-egui/src/tests/preset_import.rs similarity index 82% rename from crates/ui-egui/src/tests_preset_import.rs rename to crates/ui-egui/src/tests/preset_import.rs index 7a1abf6ee..0ed804a58 100644 --- a/crates/ui-egui/src/tests_preset_import.rs +++ b/crates/ui-egui/src/tests/preset_import.rs @@ -12,9 +12,10 @@ const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); fn app() -> Headless { - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + // Profile import needs no photos. Avoid unrelated demo renders while testing its notice. + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - h.settle(SETTLE); + assert!(h.settle(SETTLE), "the empty library settles before importing profiles"); h } @@ -27,9 +28,9 @@ fn temp_dir(tag: &str) -> std::path::PathBuf { fn import(h: &mut Headless, paths: &[&std::path::Path]) -> serde_json::Value { let paths: Vec = paths.iter().map(|p| p.to_string_lossy().to_string()).collect(); - let r = h.request("engine.execute", json!({"command": "file.importPresets", "params": {"paths": paths}}), T); - h.settle(SETTLE); - r + // Import and its toast complete synchronously before the control reply. Settling + // unrelated rendering afterwards advances simulated time and can expire the notice. + h.request("engine.execute", json!({"command": "file.importPresets", "params": {"paths": paths}}), T) } fn toast(h: &Headless) -> String { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests/preview_limit.rs similarity index 77% rename from crates/ui-egui/src/tests_preview_limit.rs rename to crates/ui-egui/src/tests/preview_limit.rs index e87e52355..07a30c8a5 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests/preview_limit.rs @@ -137,23 +137,26 @@ fn saved_limits_round_trip_and_unknown_ones_are_reset() { // ---- Behaviour in the running UI ------------------------------------------------------------- mod in_the_loupe { + use std::cell::RefCell; + use std::rc::Rc; use std::time::Duration; use serde_json::json; use crate::headless::Headless; - use crate::render::Slot; + use crate::render::{RenderOffload, Renderer, Slot}; + use crate::tests_render_fixtures::{HEIGHT, LONG_EDGE, RenderFixture, WIDTH}; use crate::{LightcraftApp, Services}; + use lightcraft_engine::SourceLevel; + use lightcraft_engine::media::{RenderJob, RenderResult, SourceRef}; const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); - /// The demo library in a 1400×900 window, the first photo open in Detail. - fn detail() -> (Headless, usize) { - detail_in([1400.0, 900.0]) - } - - fn detail_in(size: [f32; 2]) -> (Headless, usize) { + /// The original 24 MP procedural demo library in a 1400×900 window, the first photo open + /// in Detail. Kept for `profile_slider_drag`, whose meaning depends on the demo's render cost + /// and neighbour prefetch. + fn demo_detail_in(size: [f32; 2]) -> (Headless, usize) { let services = Services { png: None, ..Default::default() }; let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); let mut h = Headless::new(app, size, 1.0); @@ -168,6 +171,34 @@ mod in_the_loupe { (h, native) } + /// Two real 6000×4000 file-backed photos in a 1400×900 window, the first open in Detail. + /// Destructure the returned tuple immediately, binding the fixture before `Headless`, so the + /// app drops first. Keeping the tuple intact would drop its fixture field first. + fn detail() -> (RenderFixture, Headless, usize) { + detail_in([1400.0, 900.0]) + } + + fn detail_in(size: [f32; 2]) -> (RenderFixture, Headless, usize) { + detail_with(RenderFixture::new("preview", 2), size) + } + + fn detail_with(fixture: RenderFixture, size: [f32; 2]) -> (RenderFixture, Headless, usize) { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(fixture.session(), services); + let mut h = Headless::new(app, size, 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let (native, dims) = { + let id = h.app.session.active().expect("a photo is open"); + let p = h.app.session.catalog.photo(id).expect("it is in the catalog"); + (p.width.max(p.height) as usize, (p.width, p.height)) + }; + assert_eq!(dims, (WIDTH, HEIGHT), "the render fixture is a real 24 MP landscape 3:2 photo"); + assert_eq!(native, LONG_EDGE, "the render fixture is a real 24 MP photo"); + (fixture, h, native) + } + fn rendered_long_edge(h: &Headless) -> usize { let t = h.app.renderer.textures.get(&Slot::Main).expect("the loupe rendered"); t.size[0].max(t.size[1]) @@ -178,7 +209,7 @@ mod in_the_loupe { // cut from the photo's own pixels #[test] fn one_to_one_is_a_canvas_sized_frame_plus_a_native_window() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); let main = rendered_long_edge(&h); @@ -195,7 +226,7 @@ mod in_the_loupe { // overlay swaps the window for one with the other look #[test] fn a_mask_overlay_at_one_to_one_is_a_window_and_keeps_zoom_and_pan() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); for (command, params) in [("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2}))] { let r = h.request("engine.execute", json!({"command": command, "params": params}), T); assert_eq!(r["ok"], true, "{command}: {r}"); @@ -233,7 +264,7 @@ mod in_the_loupe { // size (slower, and its detail stops at the frame render's resolution) #[test] fn an_all_masks_overlay_at_one_to_one_is_a_window() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); for (command, params) in [ ("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2})), @@ -266,7 +297,7 @@ mod in_the_loupe { // photo's full size; switching the proof off swaps the window back to the plain look #[test] fn soft_proofing_at_one_to_one_is_a_window() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); let plain = h.app.region_view.expect("a window render without the proof"); @@ -288,7 +319,7 @@ mod in_the_loupe { // window render carrying the overlay, over a canvas-sized whole frame, as without it #[test] fn visualize_spots_at_one_to_one_is_a_window() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); let plain = h.app.region_view.expect("a window render without the overlay"); @@ -306,7 +337,7 @@ mod in_the_loupe { // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); assert!(native > 1600, "the demo photo must be larger than the cap for this to mean anything (is {native})"); h.app.ui.settings.preview_limit = 1600; h.request("engine.execute", json!({"command": "view.zoom100"}), T); @@ -321,7 +352,7 @@ mod in_the_loupe { // Given a fit view, the whole-frame render is all there is: no second render, no original decoded #[test] fn a_fit_view_makes_no_window_render() { - let (h, _) = detail(); + let (_fx, h, _) = detail(); assert_eq!(region_tile(&h), None); assert_eq!(h.app.region_view, None); } @@ -330,7 +361,7 @@ mod in_the_loupe { // rendered on its own at the zoom scale and drawn over the whole-frame render #[test] fn a_capped_one_to_one_view_gets_a_sharp_window() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.app.ui.settings.preview_limit = 1600; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -345,7 +376,7 @@ mod in_the_loupe { // Given 800 % zoom (a frame of 48000 px), the window is rendered and bounded #[test] fn eight_to_one_renders_only_what_is_visible() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.app.ui.zoom = crate::state::Zoom::Percent(800.0); h.settle(SETTLE); let (w, hh) = region_tile(&h).expect("a window render"); @@ -361,7 +392,7 @@ mod in_the_loupe { // same place of the frame (a shift, a flip or a wrong scale would not) #[test] fn the_window_lines_up_with_the_whole_frame_render() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.renderer.keep_pixels = true; h.app.ui.settings.preview_limit = 1600; h.request("engine.execute", json!({"command": "view.zoom100"}), T); @@ -369,26 +400,31 @@ mod in_the_loupe { let view = h.app.region_view.expect("a window render"); let tile = h.app.renderer.textures.get(&Slot::Region).and_then(|t| t.pixels.clone()).expect("tile pixels"); let main = h.app.renderer.textures.get(&Slot::Main).and_then(|t| t.pixels.clone()).expect("main pixels"); - let (mut sum, mut n) = (0.0f32, 0.0f32); - for j in (8..tile.size[1] - 8).step_by(37) { - for i in (8..tile.size[0] - 8).step_by(37) { - // the same point of the frame in the whole-frame render - let u = (view.window.x + i) as f32 / view.full.0 as f32; - let v = (view.window.y + j) as f32 / view.full.1 as f32; - let (mx, my) = - (((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1)); - let (a, b) = (tile.pixels[j * tile.size[0] + i], main.pixels[my * main.size[0] + mx]); - sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::() / 3.0; - n += 1.0; + let mean_difference = |offset: usize| { + let (mut sum, mut n) = (0.0f32, 0.0f32); + for j in (8..tile.size[1] - 8).step_by(37) { + for i in (8..tile.size[0] - 8).step_by(37) { + // the same point of the frame in the whole-frame render + let u = (view.window.x + i + offset) as f32 / view.full.0 as f32; + let v = (view.window.y + j) as f32 / view.full.1 as f32; + let (mx, my) = + (((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1)); + let (a, b) = (tile.pixels[j * tile.size[0] + i], main.pixels[my * main.size[0] + mx]); + sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::() / 3.0; + n += 1.0; + } } - } - assert!(sum / n < 6.0, "mean difference {:.2} / 255 between the window and the frame render", sum / n); + sum / n + }; + let difference = mean_difference(0); + assert!(difference < 6.0, "mean difference {:.2} / 255 between the window and the frame render", difference); + assert!(mean_difference(16) > 6.0, "the fixture must expose a 16-pixel horizontal misalignment"); } // Given a Fit view, a slider drag (whose Main draft is at 0.6 scale) does not start window renders #[test] fn a_slider_drag_at_fit_makes_no_window_render() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.session.begin_interaction("Exposure").unwrap(); for _ in 0..5 { h.step(); @@ -400,7 +436,7 @@ mod in_the_loupe { // Given a zoomed view with a window render, when I zoom back to Fit, then the window's texture is freed #[test] fn the_window_texture_is_freed_when_it_is_not_needed() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.settings.preview_limit = 1600; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -415,7 +451,7 @@ mod in_the_loupe { // too (while one is held the app never counts as idle and keeps the GPU copy of the original) #[test] fn leaving_a_zoomed_detail_view_frees_its_windows() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -430,7 +466,7 @@ mod in_the_loupe { // is no longer current and is replaced by one for the new look #[test] fn an_edit_replaces_the_window() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.settings.preview_limit = 1600; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -446,7 +482,7 @@ mod in_the_loupe { // Given a photo with spot removal, a zoomed view still gets its sharp window #[test] fn a_photo_with_spots_gets_a_window_too() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); let id = h.app.session.active().unwrap(); let mut s = (*h.app.session.develop_of(id).unwrap()).clone(); s.spots.push(lightcraft_develop::Spot { @@ -466,7 +502,7 @@ mod in_the_loupe { // I zoom to 1:1 and 8:1, then no texture the loupe holds is larger than that #[test] fn no_loupe_texture_exceeds_the_gpu_limit() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.max_texture_side = 2048; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -486,7 +522,7 @@ mod in_the_loupe { // the whole canvas: no strip of the magnified whole-frame render beside it #[test] fn a_window_covers_a_canvas_wider_than_2048_px() { - let (mut h, native) = detail_in([2600.0, 1200.0]); + let (_fx, mut h, native) = detail_in([2600.0, 1200.0]); assert!(native > 2600, "the photo is wider than the canvas at 1:1: {native}"); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -507,7 +543,7 @@ mod in_the_loupe { // canvas, not the zoom: a draft of the whole frame at canvas scale and a draft of the window #[test] fn a_slider_drag_when_zoomed_in_renders_canvas_sized_drafts() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.app.ui.zoom = crate::state::Zoom::Percent(400.0); h.settle(SETTLE); h.app.session.begin_interaction("Exposure").unwrap(); @@ -542,7 +578,7 @@ mod in_the_loupe { // the GPU device up front (the loupe never waits for it): with one, it is the earlier // drag's time that makes this drag small let _ = lightcraft_engine::gpu::available(); - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); let id = h.app.session.active().expect("a photo is open"); h.app.drag_scale.remember_slow(id, "Exposure"); let (main, small) = drag_exposure(&mut h); @@ -556,7 +592,7 @@ mod in_the_loupe { // the GPU device up front (the loupe never waits for it): with one, this is the case where // the GPU is there and the render still goes to the CPU let _ = lightcraft_engine::gpu::available(); - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); let lut = lightcraft_pipeline::ProofLut::new(2, vec![[0.5, 0.5, 0.5, 0.0]; 8]).expect("a valid table"); h.app.ui.proof.profile = lightcraft_pipeline::register_proof_profile(0x5eed_9f01, lut); h.app.ui.soft_proof = true; @@ -573,7 +609,7 @@ mod in_the_loupe { // Before is not a 2560 px stand-in beside a full resolution After #[test] fn side_by_side_at_one_to_one_compares_like_with_like() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -592,7 +628,7 @@ mod in_the_loupe { // beside the After's); both windows still arrive #[test] fn side_by_side_at_one_to_one_decodes_the_original_once() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); let id = h.app.session.active().expect("a photo is open"); h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; h.app.ui.zoom = crate::state::Zoom::Percent(100.0); @@ -612,7 +648,7 @@ mod in_the_loupe { // Given the Before view alone (\), only the Before gets a window #[test] fn the_original_view_windows_only_the_before() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.before_after = crate::state::BeforeAfter::Original; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -624,7 +660,7 @@ mod in_the_loupe { // Given a wipe (Split), both sides are windows, drawn over the same rect #[test] fn a_wipe_windows_both_sides() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.before_after = crate::state::BeforeAfter::Split; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -635,7 +671,7 @@ mod in_the_loupe { // Given the Before view turned off, its window is freed #[test] fn leaving_before_after_frees_the_before_window() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -651,7 +687,7 @@ mod in_the_loupe { // no stream of renders of every size in between, and the sharp window arrives once it stops #[test] fn a_pinch_holds_the_render_sizes_until_it_stops() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); let main_at_fit = rendered_long_edge(&h); let at = h.app.canvas_rect.unwrap().center(); h.request("ui.move", json!({"x": at.x, "y": at.y}), T); @@ -674,7 +710,7 @@ mod in_the_loupe { // differently, #280), no window is rendered: its coordinates are the frame's #[test] fn no_window_over_a_picture_that_is_not_the_frame() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); assert!(h.app.region_view.is_some()); @@ -699,7 +735,7 @@ mod in_the_loupe { ([2800.0, 1700.0], 400.0, false), ([1400.0, 900.0], 100.0, true), ] { - let (mut h, native) = detail_in(size); + let (mut h, native) = demo_detail_in(size); h.app.ui.soft_proof = proof; h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) }; h.settle(SETTLE); @@ -739,25 +775,188 @@ mod in_the_loupe { } } - // Given a slider drag that outpaces the renders (each tick supersedes the last), the original - // the window was cut from is kept once it is decoded: not decoded again for every tick - #[test] - fn a_drag_keeps_the_decoded_original_even_when_every_result_is_superseded() { - let (mut h, _) = detail(); - assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "fit needed no original"); - h.app.ui.zoom = crate::state::Zoom::Percent(400.0); - h.app.session.begin_interaction("Exposure").unwrap(); - let mut ticks = 0; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); - while !h.app.renderer.textures.contains_key(&Slot::Region) && std::time::Instant::now() < deadline { - let v = (ticks % 40) as f64 * 0.02; - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": v}}), T); + /// Work held by a test offload. [`RegionGate`] keeps `Slot::Region` jobs here; [`Held`] + /// keeps every job. `finished` is what the renderer's next `poll` collects. + #[derive(Default)] + struct OffloadWork { + jobs: Vec, + finished: Vec<(Slot, RenderResult, f64)>, + } + + fn take_job(work: &Rc>) -> RenderJob { + let mut w = work.borrow_mut(); + assert!(!w.jobs.is_empty(), "a render job was handed to the offload"); + w.jobs.remove(0) + } + + /// The next window job the UI asked for, stepping a few frames if the request is still a frame + /// away (deterministic frames, no wall-clock wait). + fn next_region_job(h: &mut Headless, work: &Rc>) -> RenderJob { + for _ in 0..3 { + if !work.borrow().jobs.is_empty() { + break; + } h.step(); - ticks += 1; - std::thread::sleep(std::time::Duration::from_millis(5)); } - assert!(h.app.renderer.textures.contains_key(&Slot::Region), "a window arrived"); - assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on"); + take_job(work) + } + + fn deliver(work: &Rc>, slot: Slot, result: RenderResult) { + work.borrow_mut().finished.push((slot, result, 0.0)); + } + + /// Holds the window jobs and runs every other job synchronously, returning it through + /// `finished`: the UI keeps its real `request_window` / `defer_window` / draft path, but the + /// window's original has not arrived until the test says so. + struct RegionGate(Rc>); + + impl RenderOffload for RegionGate { + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { + if slot == Slot::Region { + self.0.borrow_mut().jobs.push(job); + } else { + let result = job.run(); + self.0.borrow_mut().finished.push((slot, result, 0.0)); + } + None + } + + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + std::mem::take(&mut self.0.borrow_mut().finished) + } + } + + /// Holds every job for the test to run and deliver explicitly (the standalone collector test). + struct Held(Rc>); + + impl RenderOffload for Held { + fn try_start(&mut self, _slot: Slot, job: RenderJob) -> Option { + self.0.borrow_mut().jobs.push(job); + None + } + + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + std::mem::take(&mut self.0.borrow_mut().finished) + } + } + + // 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 + // window `request_window` asked for (respecting `defer_window`), drafted while the drag runs. + #[test] + fn a_drag_keeps_the_decoded_original_after_the_window_is_accepted() { + let (_fx, mut h, native) = detail(); + let id = h.app.session.active().expect("a photo"); + assert_eq!(native, WIDTH as usize); + assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "fit needed no original"); + h.app.session.media.set_on_screen(Some(id)); + let work = Rc::new(RefCell::new(OffloadWork::default())); + h.app.renderer.set_offload(Box::new(RegionGate(work.clone()))); + 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"); + } + + // The collector's accept branch on its own: a superseded window result still carries the + // original it decoded, and `Renderer::poll` must insert it (`session.accept(&r)` at + // render.rs:911) although a newer request for the same slot is pending. The Detail wiring + // above can only reach this once the first original is held; this standalone test proves the + // cache reuse without depending on UI timing. + #[test] + fn a_superseded_window_result_keeps_the_original_it_decoded() { + let fixture = RenderFixture::new("preview-limit-superseded", 1); + let mut session = fixture.session(); + let id = session.active().expect("a photo"); + session.media.set_on_screen(Some(id)); + let (full_w, full_h) = (WIDTH as usize, HEIGHT as usize); + let window = lightcraft_pipeline::PixelWindow { x: 1000, y: 800, w: 1300, h: 700 }; + let next_window = lightcraft_pipeline::PixelWindow { x: 1200, y: 800, w: 1300, h: 700 }; + let mut renderer = Renderer::with_worker_threads(1); + let work = Rc::new(RefCell::new(OffloadWork::default())); + renderer.set_offload(Box::new(Held(work.clone()))); + let ctx = egui::Context::default(); + + let first = session.region_job(id, full_w, full_h, window, true).expect("the first window"); + renderer.request(Slot::Region, first, 100); + let first = take_job(&work); + let old_key = first.key; + let old_request = first.request_id; + + let second = session.region_job(id, full_w, full_h, next_window, true).expect("the newer window"); + renderer.request(Slot::Region, second, 100); + let second = take_job(&work); + let new_key = second.key; + assert_ne!(new_key, old_key, "the newer window has its own identity"); + assert_eq!(renderer.wanted(Slot::Region), Some(new_key)); + + let old = first.run(); + assert!(old.rendered.is_ok(), "{:?}", old.rendered.as_ref().err()); + assert_eq!(old.request_id, old_request); + assert_eq!(old.level, SourceLevel::Full); + let loaded = old.loaded.as_ref().expect("the first window decoded the original"); + assert_eq!((loaded.image.width, loaded.image.height), (WIDTH as usize, HEIGHT as usize)); + deliver(&work, Slot::Region, old); + renderer.poll(&ctx, &mut session); + + assert_eq!(renderer.textures.get(&Slot::Region).map(|t| t.key), Some(old_key)); + assert_eq!(renderer.wanted(Slot::Region), Some(new_key)); + assert_eq!(renderer.discarded, 0, "the superseded result was accepted, not discarded"); + assert!(session.memory_report().full_source.bytes >= full_w * full_h * 3 * 4); + assert!(session.media.has_source(id, SourceLevel::Full)); + let reused = session.region_job(id, full_w, full_h, window, true).expect("another window"); + assert!(matches!(&reused.source, SourceRef::Loaded(_)), "the accepted original is reused"); } // Given an original bigger than the cache budget (a 64 MP raw's 797 MB exceeds the default @@ -765,7 +964,7 @@ mod in_the_loupe { // grid it is let go #[test] fn a_zoomed_original_over_the_budget_stays_until_the_view_changes() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.session.media.set_budget(16 << 20); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -794,7 +993,7 @@ mod in_the_loupe { #[test] fn a_wipe_paints_the_before_window_over_its_stand_in() { for mode in [crate::state::BeforeAfter::Split, crate::state::BeforeAfter::SplitTopBottom] { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.before_after = mode; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -821,7 +1020,7 @@ mod in_the_loupe { // everywhere (not only left of the line) #[test] fn holding_backslash_in_a_wipe_paints_the_before_window_across_the_canvas() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.before_after = crate::state::BeforeAfter::Split; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -848,7 +1047,7 @@ mod in_the_loupe { // margin (a window is wider than what its pane shows) does not spill into the neighbour #[test] fn side_by_side_windows_stay_in_their_panes() { - let (mut h, _) = detail_in([1800.0, 900.0]); + let (_fx, mut h, _) = detail_in([1800.0, 900.0]); h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -864,7 +1063,7 @@ mod in_the_loupe { // apart: the job key is the same, and one tile must not replace the other #[test] fn identical_before_and_after_keep_a_tile_each() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -875,7 +1074,7 @@ mod in_the_loupe { // to the whole frame at the drawn size, and tries windows again when the look changes #[test] fn a_refused_window_falls_back_to_a_full_size_render() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); assert!(h.app.region_view.is_some()); @@ -894,7 +1093,7 @@ mod in_the_loupe { // pinch stops: a new, ever wider window every frame is not asked for #[test] fn pinching_out_asks_for_no_new_windows() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.zoom = crate::state::Zoom::Percent(400.0); h.settle(SETTLE); let key = h.app.region_view.expect("a window").key; @@ -910,7 +1109,7 @@ mod in_the_loupe { // slider drag still reuses them: they are not cleared every frame #[test] fn a_drag_over_the_stage_budget_does_not_clear_the_open_views() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.ui.zoom = crate::state::Zoom::Percent(100.0); h.settle(SETTLE); let held = |h: &Headless| { @@ -947,7 +1146,7 @@ mod in_the_loupe { // whole-frame render, magnified and blurred, was shown) #[test] fn a_display_profile_keeps_the_sharp_window_at_one_to_one() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); use_display_profile(&mut h); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); @@ -963,7 +1162,7 @@ mod in_the_loupe { // …and in Compare, each pane's window too #[test] fn a_display_profile_keeps_the_compare_windows_at_one_to_one() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); use_display_profile(&mut h); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); assert_eq!(r["ok"], true, "{r}"); @@ -982,7 +1181,7 @@ mod in_the_loupe { // a display #[test] fn with_the_screen_profile_the_window_is_drawn() { - let (mut h, native) = detail(); + let (_fx, mut h, native) = detail(); h.app.services.screen_profile = Some(Box::new(|| Some(lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)))); h.step(); assert!(h.app.ui.settings.display_profile.is_empty(), "no profile chosen"); @@ -1000,20 +1199,20 @@ mod in_the_loupe { /// RAM or more: room for both panes' 24 MP originals (288 MB each as linear float). const COMPARE_CACHE: usize = 768 << 20; - /// Compare view of the demo library at `zoom`, settled. + /// Compare view of the render fixture at `zoom`, settled. /// /// The session's cache budget is set here rather than taken from the process-wide memory /// budget, which other tests change while they run (Settings ▸ Performance ▸ 1 GB leaves a /// 512 MiB share, too small for two originals, so one pane's was evicted). - fn compare_at(zoom: crate::state::Zoom) -> Headless { - let (mut h, _) = detail(); + fn compare_at(zoom: crate::state::Zoom) -> (RenderFixture, Headless) { + let (fixture, mut h, _) = detail(); h.app.session.media.set_budget(COMPARE_CACHE); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); h.app.ui.zoom = zoom; h.settle(SETTLE); - h + (fixture, h) } fn native_of(h: &Headless, id: lightcraft_catalog::PhotoId) -> usize { @@ -1025,7 +1224,7 @@ mod in_the_loupe { // cut from its own photo's pixels, not the 2560 px whole-frame render magnified #[test] fn compare_at_one_to_one_gets_a_sharp_window_per_pane() { - let h = compare_at(crate::state::Zoom::Percent(100.0)); + let (_fx, h) = compare_at(crate::state::Zoom::Percent(100.0)); let (sel, cand) = h.app.ui.compare.map(|(a, b)| (lightcraft_catalog::PhotoId(a), lightcraft_catalog::PhotoId(b))).expect("a pair"); for (i, id) in [(0u8, sel), (1u8, cand)] { let whole = h.app.renderer.textures.get(&Slot::Compare(i)).expect("the whole-frame render"); @@ -1043,7 +1242,7 @@ mod in_the_loupe { // Given Compare at fit, nothing more is rendered than before windows #[test] fn compare_at_fit_makes_no_window() { - let h = compare_at(crate::state::Zoom::Fit); + let (_fx, h) = compare_at(crate::state::Zoom::Fit); assert_eq!(h.app.compare_windows, [None, None]); assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(0)) && !h.app.renderer.textures.contains_key(&Slot::CompareRegion(1))); } @@ -1052,7 +1251,7 @@ mod in_the_loupe { // are freed, and back in Detail only one original is kept decoded #[test] fn leaving_a_zoomed_compare_frees_its_windows() { - let mut h = compare_at(crate::state::Zoom::Percent(100.0)); + let (_fx, mut h) = compare_at(crate::state::Zoom::Percent(100.0)); assert!(h.app.renderer.textures.contains_key(&Slot::CompareRegion(1))); h.app.ui.zoom = crate::state::Zoom::Fit; h.settle(SETTLE); @@ -1072,7 +1271,7 @@ mod in_the_loupe { // at the same place of the frame, and it is painted over that render, inside the pane #[test] fn a_compare_window_lines_up_with_its_pane() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); h.app.renderer.keep_pixels = true; h.request("engine.execute", json!({"command": "view.compare"}), T); h.app.ui.zoom = crate::state::Zoom::Percent(100.0); @@ -1082,19 +1281,24 @@ mod in_the_loupe { let view = h.app.compare_windows[i as usize].expect("a window"); let tile = h.app.renderer.textures.get(&Slot::CompareRegion(i)).and_then(|t| t.pixels.clone()).expect("tile pixels"); let main = h.app.renderer.textures.get(&Slot::Compare(i)).and_then(|t| t.pixels.clone()).expect("pane pixels"); - let (mut sum, mut n) = (0.0f32, 0.0f32); - for j in (8..tile.size[1] - 8).step_by(37) { - for x in (8..tile.size[0] - 8).step_by(37) { - let u = (view.window.x + x) as f32 / view.full.0 as f32; - let v = (view.window.y + j) as f32 / view.full.1 as f32; - let (mx, my) = - (((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1)); - let (a, b) = (tile.pixels[j * tile.size[0] + x], main.pixels[my * main.size[0] + mx]); - sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::() / 3.0; - n += 1.0; + let mean_difference = |offset: usize| { + let (mut sum, mut n) = (0.0f32, 0.0f32); + for j in (8..tile.size[1] - 8).step_by(37) { + for x in (8..tile.size[0] - 8).step_by(37) { + let u = (view.window.x + x + offset) as f32 / view.full.0 as f32; + let v = (view.window.y + j) as f32 / view.full.1 as f32; + let (mx, my) = + (((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1)); + let (a, b) = (tile.pixels[j * tile.size[0] + x], main.pixels[my * main.size[0] + mx]); + sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::() / 3.0; + n += 1.0; + } } - } - assert!(sum / n < 6.0, "pane {i}: mean difference {:.2} / 255 between the window and the pane's render", sum / n); + sum / n + }; + let difference = mean_difference(0); + assert!(difference < 6.0, "pane {i}: mean difference {:.2} / 255 between the window and the pane's render", difference); + assert!(mean_difference(16) > 6.0, "the fixture must expose a 16-pixel horizontal misalignment"); let (whole, window) = (drawn(&h, Slot::Compare(i)), drawn(&h, Slot::CompareRegion(i))); assert!(!whole.is_empty() && !window.is_empty(), "pane {i}: both are painted"); assert!(window[0].0 > whole[0].0, "pane {i}: the window is painted over the whole-frame render"); @@ -1109,7 +1313,7 @@ mod in_the_loupe { // drag on the active photo asks for no new window until it ends #[test] fn reference_view_at_one_to_one_gets_windows_and_a_drag_keeps_them() { - let (mut h, _) = detail(); + let (_fx, mut h, _) = detail(); let r = h.request("engine.execute", json!({"command": "photo.setReference"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.key", json!({"key": "r", "shift": true}), T); diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests/print.rs similarity index 87% rename from crates/ui-egui/src/tests_print.rs rename to crates/ui-egui/src/tests/print.rs index 28838f409..68f3d328d 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests/print.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use crate::headless::Headless; use crate::state::Dialog; +use crate::tests_fixtures::SyntheticLibrary; use crate::{LightcraftApp, Services}; const T: Duration = Duration::from_secs(30); @@ -214,6 +215,50 @@ fn templates_save_apply_and_delete_from_the_dialog() { assert!(h.app.session.print_templates.is_empty()); } +#[test] +fn templates_export_and_import_from_the_dialog() { + let dir = std::env::temp_dir().join(format!("lightcraft-ui-print-templates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Sheet 5x6.lcprint").to_string_lossy().to_string(); + let suggested = Arc::new(Mutex::new(Vec::::new())); + let names = suggested.clone(); + let (save_to, pick) = (file.clone(), file.clone()); + let services = Services { + save_print_template: Some(Box::new(move |name: &str| { + names.lock().unwrap().push(name.to_string()); + Some(save_to.clone()) + })), + pick_print_templates: Some(Box::new(move || vec![pick.clone()])), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + click(&mut h, "button:printTemplateExport-0"); + assert_eq!(*suggested.lock().unwrap(), vec!["Sheet 5x6.lcprint".to_string()]); + assert!(std::path::Path::new(&file).is_file()); + click(&mut h, "button:printTemplateImport"); + let names: Vec<&str> = h.app.session.print_templates.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, ["Sheet 5x6", "Sheet 5x6 (2)"]); + assert_eq!(h.app.session.print_templates[1].settings, h.app.session.print_templates[0].settings); + assert!(has(&h, "button:printTemplateExport-1"), "the imported template is listed"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn template_files_need_the_hosts_file_dialogs() { + let mut h = demo(Services::default()); + open_print(&mut h); + h.app.ui.print.template_name = "Plain".into(); + click(&mut h, "button:printSaveTemplate"); + assert!(has(&h, "button:printTemplateApply-0")); + assert!(!has(&h, "button:printTemplateExport-0"), "no save dialog: no Export…"); + assert!(!has(&h, "button:printTemplateImport"), "no open dialog: no Import…"); +} + #[test] fn print_to_pdf_picks_a_file_and_runs_in_the_background() { let asked = Arc::new(Mutex::new(Vec::::new())); @@ -358,8 +403,14 @@ fn print_job_colour_controls_reach_the_job_and_the_pdf() { #[test] fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { - let mut h = demo(Services { png: None, ..Default::default() }); + // This counts thumbnail requests across print-cell sizes, not native-image throughput. + // Keep the demo's twelve-photo contact sheet with real files that decode cheaply. + let library = SyntheticLibrary::with_photos("print-thumbnail-reuse", 12); + let mut h = Headless::new(library.app(), [1400.0, 900.0], 1.0); + assert!(h.settle(T), "the print thumbnail fixture settles"); + assert_eq!(h.app.session.catalog.len(), 12, "the contact sheet retains twelve photos"); let before = h.app.renderer.thumb_jobs_built; + assert!(before > 0, "the fixture exercises actual thumbnail jobs"); cmd(&mut h, "dialog.print", json!({})); for style in ["contactSheet", "picturePackage", "singleImage"] { cmd(&mut h, "print.set", json!({"layout": {"style": style}})); diff --git a/crates/ui-egui/src/tests_profile_menu.rs b/crates/ui-egui/src/tests/profile_menu.rs similarity index 100% rename from crates/ui-egui/src/tests_profile_menu.rs rename to crates/ui-egui/src/tests/profile_menu.rs diff --git a/crates/ui-egui/src/tests_profiles_layout.rs b/crates/ui-egui/src/tests/profiles_layout.rs similarity index 100% rename from crates/ui-egui/src/tests_profiles_layout.rs rename to crates/ui-egui/src/tests/profiles_layout.rs diff --git a/crates/ui-egui/src/tests_progress.rs b/crates/ui-egui/src/tests/progress.rs similarity index 99% rename from crates/ui-egui/src/tests_progress.rs rename to crates/ui-egui/src/tests/progress.rs index 476b8a1a6..09e9234a0 100644 --- a/crates/ui-egui/src/tests_progress.rs +++ b/crates/ui-egui/src/tests/progress.rs @@ -198,7 +198,7 @@ fn the_thumbnail_placeholder_stands_apart_in_every_theme() { fn a_failed_filmstrip_thumbnail_is_announced_with_its_reason() { let spoken = |failed: bool| { let (nodes, _, _) = frames(Vec::new(), |ui| { - let resp = ui.interact(Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 80.0)), egui::Id::new("film-test"), egui::Sense::click()); + let resp = ui.interact(Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 80.0)), egui::Id::unique("film-test"), egui::Sense::click()); crate::widgets::announce(&resp, crate::panels::detail::film_spoken("IMG_0001.jpg", failed)); }); nodes.into_iter().find(|n| n.role == "Button").map(|n| n.label) diff --git a/crates/ui-egui/src/tests_quit_unsaved.rs b/crates/ui-egui/src/tests/quit_unsaved.rs similarity index 67% rename from crates/ui-egui/src/tests_quit_unsaved.rs rename to crates/ui-egui/src/tests/quit_unsaved.rs index ef6fa8da4..f703c24a6 100644 --- a/crates/ui-egui/src/tests_quit_unsaved.rs +++ b/crates/ui-egui/src/tests/quit_unsaved.rs @@ -106,6 +106,44 @@ fn quitting_with_unsaved_changes_asks_first() { assert!(h.app.quit_confirmed && crate::panels::notices::may_close(&mut h.app)); } +/// "Try Saving Again" can recreate the identical unsaved prompt. It is still a new prompt: its +/// first frame is shielded and focus goes back to the safe Cancel, never the Retry button that was +/// just used. +#[test] +fn retrying_an_unsaved_prompt_resets_focus_to_safe_cancel() { + let store = Unplugged::default(); + let mut s = lightcraft_engine::Session::new(); + let stores = LibraryStores { dir: "unplugged".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + let app = LightcraftApp::new(s, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.step(); + store.fail.store(true, Ordering::SeqCst); + let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Kept"}}), T); + assert_eq!(r["ok"], false, "{r}"); + assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask"); + h.step(); + h.step(); + let focused = |h: &Headless| h.view.ctx.memory(|m| m.focused()); + let cancel = h.app.widget_hits.iter().rev().find(|(w, _)| w == "button:quitCancel").map(|(_, hit)| hit.id).expect("Cancel has a real response"); + let retry = h.app.widget_hits.iter().rev().find(|(w, _)| w == "button:quitRetry").map(|(_, hit)| hit.id).expect("Retry has a real response"); + assert_eq!(focused(&h), Some(cancel), "the safe Cancel starts focused"); + assert_eq!(h.request("ui.key", json!({"key": "Tab"}), T)["ok"], true); + assert_eq!(focused(&h), Some(retry), "Tab moved focus to Retry"); + // Activate Retry; the drive is still unplugged, so the same prompt is recreated. + assert_eq!(h.request("ui.key", json!({"key": "Space"}), T)["ok"], true); + h.step(); + h.step(); + assert!(matches!(h.app.quit_prompt, Some(crate::QuitPrompt::Unsaved(_))), "the retried save still asks"); + let cancel_after = + h.app.widget_hits.iter().rev().find(|(w, _)| w == "button:quitCancel").map(|(_, hit)| hit.id).expect("the new Cancel has a real response"); + let retry_after = + h.app.widget_hits.iter().rev().find(|(w, _)| w == "button:quitRetry").map(|(_, hit)| hit.id).expect("the new Retry has a real response"); + assert_ne!(cancel_after, retry_after); + assert_eq!(focused(&h), Some(cancel_after), "the recreated prompt reset focus to safe Cancel"); + assert!(!h.app.quit_confirmed, "nothing quit"); +} + #[test] fn damaged_settings_file_is_shown() { let files = MemStore::new(); diff --git a/crates/ui-egui/src/tests_rating.rs b/crates/ui-egui/src/tests/rating.rs similarity index 99% rename from crates/ui-egui/src/tests_rating.rs rename to crates/ui-egui/src/tests/rating.rs index f89d1267b..d473508d3 100644 --- a/crates/ui-egui/src/tests_rating.rs +++ b/crates/ui-egui/src/tests/rating.rs @@ -230,7 +230,7 @@ fn a_rating_changed_from_outside_moves_the_focus_to_the_tab_stop() { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { let _ = row(rating.get()).show(ui); - stars_focused = (0..5).map(|i| ctx.memory(|m| m.has_focus(ui.id().with(("rating-star", "t", i as u8))))).collect(); + stars_focused = (0..5).map(|i| ctx.memory(|m| m.has_focus(ui.scope_id().with(("rating-star", "t", i as u8))))).collect(); }); out.textures_delta.clear(); } diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests/recheck.rs similarity index 100% rename from crates/ui-egui/src/tests_recheck.rs rename to crates/ui-egui/src/tests/recheck.rs diff --git a/crates/ui-egui/src/tests_rename_person.rs b/crates/ui-egui/src/tests/rename_person.rs similarity index 100% rename from crates/ui-egui/src/tests_rename_person.rs rename to crates/ui-egui/src/tests/rename_person.rs diff --git a/crates/ui-egui/src/tests/render_fixtures.rs b/crates/ui-egui/src/tests/render_fixtures.rs new file mode 100644 index 000000000..b79bbad36 --- /dev/null +++ b/crates/ui-egui/src/tests/render_fixtures.rs @@ -0,0 +1,179 @@ +//! Test-only, real, file-backed photo fixtures for the render tests. +//! +//! The input/modal tests use a tiny `SyntheticLibrary` (64×48) that cannot exercise native-size +//! decoding, a 1300×700 region window, a >16 MiB decoded original or Compare's two originals. +//! These fixtures write real landscape 3:2 PNGs at the demo library's 24 MP size and register +//! them as ordinary `Source::File` photos, so preview, Before/After and slideshow tests decode +//! and render through the same path as a user's files. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +use lightcraft_catalog::{Op, Photo, Source}; +use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, encode_png}; +use lightcraft_engine::{Selection, Session}; + +/// The demo library's photo size: landscape 3:2, 24 MP. +pub(crate) const WIDTH: u32 = 6000; +pub(crate) const HEIGHT: u32 = 4000; +/// The long edge the native-resolution tests look for. +pub(crate) const LONG_EDGE: usize = WIDTH as usize; +/// The pixel count of every fixture photo. +pub(crate) const MEGAPIXELS: u64 = WIDTH as u64 * HEIGHT as u64; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); +type FixtureCache = Mutex>>; +/// One generated set per (tag, count) in this test process: parallel tests share the same +/// read-only PNGs instead of each encoding 2–3 24 MP images (and spiking memory). +static FIXTURES: OnceLock = OnceLock::new(); + +struct FixtureFiles { + dir: PathBuf, + paths: Vec, + sizes: Vec, +} + +impl Drop for FixtureFiles { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Owns (with other users) a temporary directory of real PNGs. Keep it alive until after the +/// [`crate::headless::Headless`] built from [`RenderFixture::session`] has dropped: the renderer +/// reads these paths lazily. +pub(crate) struct RenderFixture { + files: Arc, +} + +impl RenderFixture { + /// Write (or reuse) `count` deterministic, smooth, non-neutral landscape gradient PNGs. + pub(crate) fn new(tag: &str, count: usize) -> Self { + assert!(count > 0, "a render fixture needs at least one photo"); + let key = (tag.to_string(), count); + let cache = FIXTURES.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(files) = cache.get(&key).and_then(Weak::upgrade) { + return RenderFixture { files }; + } + let files = Arc::new(build_files(tag, count)); + cache.insert(key, Arc::downgrade(&files)); + RenderFixture { files } + } + + /// A session with `with_fs` loaders, one default-edited `Source::File` photo per fixture, + /// distinct ids and captured times in a stable order, with the first visible photo selected. + pub(crate) fn session(&self) -> Session { + let mut session = Session::new().with_fs(); + for (i, path) in self.files.paths.iter().enumerate() { + let id = session.catalog.alloc_photo_id(); + let name = format!("RENDER_{i:04}.PNG"); + let mut photo = Photo::new(id, Source::File { path: path.clone() }, &name, "PNG", WIDTH, HEIGHT, "2026-01-01T10:00:00"); + // Distinct captured times keep `visible_cloned()` deterministic (the default sort is + // capture date, newest first) and make every PhotoId its own file. + photo.captured = Some(format!("2026-01-01T10:{:02}:{:02}", i / 60, i % 60)); + photo.file_size = self.files.sizes.get(i).copied().unwrap_or(0); + session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).expect("add a render fixture photo"); + } + session.keep_active_in_view(); + let first = session.visible_cloned().first().copied().expect("the fixture has a visible photo"); + session.selection = Selection::single(first); + session + } + + pub(crate) fn paths(&self) -> &[String] { + &self.files.paths + } +} + +fn build_files(tag: &str, count: usize) -> FixtureFiles { + let n = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("lightcraft-render-fixtures-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create the render fixture directory"); + + let mut paths = Vec::with_capacity(count); + let mut sizes = Vec::with_capacity(count); + for i in 0..count { + let path = dir.join(format!("render-{i:02}.png")); + let size = write_gradient(&path, i); + paths.push(path.to_string_lossy().into_owned()); + sizes.push(size); + } + FixtureFiles { dir, paths, sizes } +} + +/// A smooth, non-wrapping, non-neutral gradient: R rises with x, G with y, B falls with x. The +/// superimposed spatial waves make shifted pixels distinguishable in alignment regressions. The +/// centre stays non-neutral, so the white-balance picker sees a real colour and an +/// exposure lift of 1.5 EV is unmistakably brighter. `variant` shifts it slightly so no two +/// fixture files have the same bytes. +fn write_gradient(path: &Path, variant: usize) -> u64 { + let w = WIDTH as usize; + let h = HEIGHT as usize; + let shift_r = (variant % 7) as f32 * 4.0; + let shift_b = (variant % 5) as f32 * 3.0; + let r_base: Vec = (0..w) + .map(|x| { + let u = x as f32 / (w - 1) as f32; + clamp8(80.0 + 120.0 * u + shift_r + 44.0 * (std::f32::consts::TAU * x as f32 / 160.0).sin()) + }) + .collect(); + let b_base: Vec = (0..w) + .map(|x| { + let u = x as f32 / (w - 1) as f32; + clamp8(40.0 + 100.0 * (1.0 - u) + shift_b + 22.0 * (std::f32::consts::TAU * x as f32 / 160.0).sin()) + }) + .collect(); + + let mut bytes = vec![0u8; w * h * 4]; + for (y, row) in bytes.chunks_exact_mut(w * 4).enumerate() { + let v = y as f32 / (h - 1) as f32; + let wave = (std::f32::consts::TAU * y as f32 / 192.0).sin(); + let g = clamp8(60.0 + 100.0 * v + 44.0 * wave); + for (x, pixel) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() { + pixel[0] = r_base[x]; + pixel[1] = g; + pixel[2] = clamp8(f32::from(b_base[x]) + 22.0 * wave); + pixel[3] = 255; + } + } + + let image = EncodeImage::new(WIDTH, HEIGHT, 4, Samples::U8(&bytes)); + let encoded = encode_png(&image, &EncodeMeta::default()).expect("encode a render fixture PNG"); + std::fs::write(path, &encoded).expect("write a render fixture PNG"); + encoded.len() as u64 +} + +fn clamp8(v: f32) -> u8 { + v.clamp(0.0, 255.0).round() as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The fixture must stay a real 24 MP landscape 3:2 PNG: a fast little image would silently + /// weaken every render assertion that depends on native pixels and a >16 MiB decoded source. + #[test] + fn render_fixtures_are_real_24mp_landscape_pngs() { + assert_eq!((WIDTH, HEIGHT), (6000, 4000)); + assert_eq!(MEGAPIXELS, 24_000_000, "the demo photos these replace are 24 MP"); + let fixture = RenderFixture::new("preview", 2); + assert_eq!(fixture.paths().len(), 2); + for path in fixture.paths() { + let bytes = std::fs::read(path).expect("read the fixture PNG"); + let header = lightcraft_codecs::read_header(&bytes).expect("read the fixture PNG header"); + assert_eq!((header.width, header.height), (WIDTH, HEIGHT), "{path}"); + assert_eq!(header.format, lightcraft_codecs::Format::Png, "{path}"); + // A full-resolution decode is width × height linear RGB f32 samples, well over the + // 16 MiB budget `a_zoomed_original_over_the_budget...` checks. + let decoded_bytes = u64::from(header.width) * u64::from(header.height) * 3 * 4; + assert!(decoded_bytes > (16 << 20), "{path}: a decoded source of {decoded_bytes} bytes"); + } + // The files are distinct so each PhotoId is its own original. + assert_ne!(fixture.paths()[0], fixture.paths()[1]); + } +} diff --git a/crates/ui-egui/src/tests_rules_editor.rs b/crates/ui-egui/src/tests/rules_editor.rs similarity index 100% rename from crates/ui-egui/src/tests_rules_editor.rs rename to crates/ui-egui/src/tests/rules_editor.rs diff --git a/crates/ui-egui/src/tests_sam_setup.rs b/crates/ui-egui/src/tests/sam_setup.rs similarity index 100% rename from crates/ui-egui/src/tests_sam_setup.rs rename to crates/ui-egui/src/tests/sam_setup.rs diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests/scroll.rs similarity index 99% rename from crates/ui-egui/src/tests_scroll.rs rename to crates/ui-egui/src/tests/scroll.rs index dd999108a..da61d4836 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests/scroll.rs @@ -215,6 +215,7 @@ fn notch(h: &mut Headless, dy: f32) { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, modifiers: egui::Modifiers::NONE, }); h.step(); diff --git a/crates/ui-egui/src/tests_slider_reset.rs b/crates/ui-egui/src/tests/slider_reset.rs similarity index 100% rename from crates/ui-egui/src/tests_slider_reset.rs rename to crates/ui-egui/src/tests/slider_reset.rs diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests/slideshow.rs similarity index 70% rename from crates/ui-egui/src/tests_slideshow.rs rename to crates/ui-egui/src/tests/slideshow.rs index f4f54c3ad..01a468ed8 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests/slideshow.rs @@ -2,14 +2,18 @@ //! prepared ahead off the UI thread, transitions only between ready slides, keys and controls. //! Simulated time: every headless frame is 1/60 s, so nothing here sleeps. +use std::cell::Cell; +use std::rc::Rc; use std::time::Duration; use lightcraft_catalog::PhotoId; use lightcraft_engine::slideshow::Item; use serde_json::json; -use crate::headless::Headless; +use crate::headless::{Headless, RenderGate}; use crate::render::Slot; +use crate::tests_fixtures::SyntheticLibrary; +use crate::tests_render_fixtures::{HEIGHT, RenderFixture, WIDTH}; use crate::{LightcraftApp, Services}; const T: Duration = Duration::from_secs(20); @@ -27,6 +31,31 @@ fn grid_sized(size: [f32; 2]) -> Headless { h } +/// The slideshow's 1000×700 window over `count` real 6000×4000 file-backed photos. Used only by +/// `next_slides_are_prepared_before_they_are_due`, where the 24 MP decode/render is the point: +/// destructure the tuple immediately with the fixture binding first, so the renderer drops first. +fn fixture_grid(count: usize, open: Rc>) -> (RenderFixture, Headless) { + let fixture = RenderFixture::new("slideshow", count); + assert_eq!(fixture.paths().len(), count); + for (index, path) in fixture.paths().iter().enumerate() { + assert!(std::path::Path::new(path).is_file(), "the fixture photo exists"); + assert!(!fixture.paths()[..index].contains(path), "each fixture photo has its own path"); + } + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(fixture.session(), services); + let mut h = Headless::new(app, [1000.0, 700.0], 1.0); + // Control native grid work before its first frame too: asynchronous setup must not + // consume the wall-clock budget before the slideshow scheduling assertions begin. + assert!(open.get(), "the native grid setup gate starts open"); + h.app.renderer.set_offload(Box::new(RenderGate::new(open))); + assert!(h.settle(SETTLE), "the grid renders settle before controlling slide renders"); + assert_eq!(h.app.session.catalog.len(), count, "the slideshow fixture photos"); + let id = h.app.session.active().expect("a photo is open"); + let photo = h.app.session.catalog.photo(id).expect("it is in the catalog"); + assert_eq!((photo.width, photo.height), (WIDTH, HEIGHT), "the slideshow fixture is a real 24 MP photo"); + (fixture, h) +} + fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { let r = h.request("engine.execute", json!({"command": command, "params": params}), T); assert_eq!(r["ok"], true, "{command}: {r}"); @@ -51,18 +80,34 @@ fn photo_up(h: &Headless) -> PhotoId { play(h).current_photo().expect("a photo is up") } +fn rect_json(r: egui::Rect) -> serde_json::Value { + json!([r.left(), r.top(), r.width(), r.height()]) +} + fn slides_held(h: &Headless) -> usize { h.app.renderer.textures.keys().filter(|s| matches!(s, Slot::Slide(_))).count() } #[test] fn slideshow_advances_pauses_and_ends() { - let mut h = grid(); + // This tests the automatic playback clock and controls; native 24 MP rendering + // is exercised separately by next_slides_are_prepared_before_they_are_due. + let library = SyntheticLibrary::with_photos("slideshow-automatic-controls", 3); + let mut h = Headless::new(library.app(), [1000.0, 700.0], 1.0); + assert!(h.settle(SETTLE), "the automatic playback fixture settles"); let first = h.app.session.visible_cloned()[0]; - run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + // The fixture selects its first imported photo; newest-first view order differs. + // Establish the same first-in-view starting selection as the original demo test. + run(&mut h, "library.select", json!({"ids": [first.0]})); + assert_eq!(h.app.session.active(), Some(first), "the first visible photo is selected before playback"); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0, "manual": false})); + assert!(!play(&h).settings().manual, "playback advances automatically"); assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); assert_eq!(h.app.session.active(), Some(first), "starts at the first photo in view"); assert!(h.step_until(SETTLE, |h| h.app.session.active() != Some(first)), "the next photo comes up and is selected"); + let advanced = photo_up(&h); + assert_eq!(h.app.session.active(), Some(advanced), "selection follows automatic playback"); + assert!(has_slide(&h, advanced), "automatic playback shows a rendered next photo"); // Space pauses: nothing moves let r = h.request("ui.key", json!({"key": "space"}), T); assert_eq!(r["ok"], true, "{r}"); @@ -82,34 +127,66 @@ fn slideshow_advances_pauses_and_ends() { } #[test] -fn next_slides_are_prepared_before_they_are_due() { +fn turning_off_the_full_screen_preview_frees_the_slides() { let mut h = grid(); - // a long slide: anything rendered for the next ones is rendered ahead of time - run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7})); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "preparePreviews": false})); + assert!(h.step_until(SETTLE, |h| slides_held(h) > 0), "a slide is rendered"); + let r = run(&mut h, "view.fullScreenPreview", json!({})); + assert_eq!(r["fullscreen"], false); + assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none(), "the toggle ends the slideshow"); + assert_eq!(slides_held(&h), 0, "slide textures freed"); +} + +#[test] +fn next_slides_are_prepared_before_they_are_due() { + let open = Rc::new(Cell::new(true)); + let (fx, mut h) = fixture_grid(8, open.clone()); + // Grid setup is complete. Hold photo slide jobs until their ahead requests are + // observable; opening the gate runs the actual native decode/render synchronously. + open.set(false); + let reply = run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7})); + assert_eq!(reply["settings"]["preparePreviews"], true); + assert_eq!(reply["settings"]["slideDuration"], 60.0); let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD); - assert_eq!(ahead.len(), 2); + assert_eq!(ahead.len(), crate::panels::slideshow::AHEAD); let start = h.time(); - // the slide up and the next two; their renders can finish in any order + // The current photo and next two are queued before any slide render completes. let current = photo_up(&h); + for item in std::iter::once(Item::Photo(current)).chain(ahead.iter().copied()) { + assert!(h.app.renderer.is_pending(Slot::Slide(item)), "requested before it is due: {item:?}"); + assert!(!has_slide(&h, item), "not rendered before the gate opens: {item:?}"); + } + assert!(play(&h).remaining(h.time()) > 59.0, "the first slide is not due before rendering"); + // The gate executes the actual decode/render work synchronously when polled. + open.set(true); assert!( h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))), "the slide up and the next two slides are rendered" ); assert!(h.time() - start < 60.0, "before the first is due"); assert_eq!(photo_up(&h), current, "still on the first slide"); - h.settle(SETTLE); + assert!(play(&h).remaining(h.time()) > 59.0, "renders are accepted before the first slide is due"); + assert!(h.settle(SETTLE), "enabled-prefetch renders settle"); + drop(h); + drop(fx); // off: only the slide up is rendered until the next one is due - let mut h = grid(); - run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false})); + let open = Rc::new(Cell::new(true)); + let (_fx2, mut h) = fixture_grid(8, open.clone()); + open.set(false); + let reply = run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false})); + assert_eq!(reply["settings"]["preparePreviews"], false); let next = play(&h).upcoming(1)[0]; let current = photo_up(&h); - assert!(h.step_until(SETTLE, |h| has_slide(h, current))); for _ in 0..30 { h.step(); } + assert!(h.app.renderer.is_pending(Slot::Slide(Item::Photo(current))), "the current slide is requested"); assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "not prepared"); - h.settle(SETTLE); + open.set(true); + assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the current slide renders once allowed"); + assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "still not prepared"); + assert!(h.settle(SETTLE), "disabled-prefetch renders settle"); } #[test] @@ -154,6 +231,76 @@ fn arrows_cut_and_repeat_off_ends_after_the_last_slide() { h.settle(SETTLE); } +#[test] +fn a_manual_slideshow_waits_for_the_arrow_keys() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + // the library setting, as the Settings checkbox sets it + run(&mut h, "slideshow.playback", json!({"manual": true})); + let r = run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + assert_eq!(r["settings"]["manual"], true, "{r}"); + // once the first two slides are rendered, and far longer than the slide duration: the first + // photo stays up + assert!(h.step_until(SETTLE, |h| has_slide(h, photos[0]) && has_slide(h, photos[1])), "the slides are rendered"); + for _ in 0..120 { + h.step(); + } + assert_eq!(photo_up(&h), photos[0], "nothing advances on its own"); + let r = h.request("ui.key", json!({"key": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(photo_up(&h), photos[1]); + assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows"); + let s = run(&mut h, "slideshow.togglePause", json!({})); + assert_eq!((s["manual"].clone(), s["remaining"].clone()), (json!(true), json!(null)), "{s}"); + run(&mut h, "slideshow.end", json!({})); + assert!(h.app.ui.slideshow.is_none()); + // off again: it advances by itself + run(&mut h, "slideshow.playback", json!({"manual": false})); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + let up = photo_up(&h); + assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.current_photo() != Some(up)))); + run(&mut h, "slideshow.end", json!({})); + // the Settings checkbox turns it on + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + let target = reveal(&mut h, "check:settings.slideshowManual"); + let diagnose = |phase: &str, h: &Headless| { + let id = "check:settings.slideshowManual"; + let rect = latest_widget_rect(h, id); + let hit = crate::control::widget_hit(&h.app, id); + let visible = rect.is_some_and(|r| crate::control::widget_target_visible(&h.view.ctx, hit, r.center())); + eprintln!( + "manual-checkbox {phase}: manual={} time={} frames={} target={target:?} rect={rect:?} room={:?} hit={hit:?} visible={visible} input={:?} dialog={:?} notices={:?} quit_prompt={} notice_barrier={} library_problem={} library_barrier={} in_flight={} pending={:?} toast={:?}", + h.app.session.slideshow.manual, + h.time(), + h.frames(), + latest_widget_rect(h, "settings:pageRoom"), + h.view.ctx.input(|i| (i.is_scrolling(), i.modifiers, i.keys_down.clone(), i.pointer.any_down())), + h.app.ui.dialog, + h.app.notices, + h.app.quit_prompt.is_some(), + crate::panels::notices::blocks_input(&h.app), + h.app.library_problem.is_some(), + crate::panels::library_problem::blocks_file_drops(&h.app), + h.app.renderer.in_flight(), + h.app.renderer.pending_slots(), + h.app.ui.toast, + ); + }; + diagnose("before-click", &h); + assert!(!h.app.session.slideshow.manual, "the checkbox starts unchecked before the real click"); + let journal_before = h.app.session.journal.len(); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowManual"}), T); + diagnose("after-request", &h); + eprintln!("manual-checkbox reply={r} journal_since_click={:?}", h.app.session.journal.get(journal_before..)); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + diagnose("after-existing-step", &h); + eprintln!("manual-checkbox journal_after_existing_step={:?}", h.app.session.journal.get(journal_before..)); + assert!(h.app.session.slideshow.manual, "the checkbox goes through slideshow.playback"); + h.settle(SETTLE); +} + #[test] fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() { let order = |seed: u64| { @@ -237,15 +384,45 @@ fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() { run(&mut h, "view.slideshowOptions", json!({})); h.step(); assert!(h.app.widgets.iter().any(|(w, _)| w == "check:settings.slideshowRandom")); + // The preview render is a layout input too: wait for the selected photo's real slide before + // scrolling and clicking, so its completion cannot move the checkbox between those steps. + let preview = h.app.session.active().expect("the demo library has a selected photo"); + assert!( + h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == preview)), + "the real preview is rendered" + ); // (below the Templates at the top of the tab) - reveal(&mut h, "check:settings.slideshowRandom"); + let validated = reveal(&mut h, "check:settings.slideshowRandom"); let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); assert_eq!(r["ok"], true, "{r}"); - // (waits for it: one frame isn't always enough on a loaded machine) - assert!(h.step_until(T, |h| h.app.session.slideshow.random_order), "the checkbox goes through slideshow.playback"); + assert_eq!(r["result"]["rect"], rect_json(validated), "the click used the rect reveal validated"); + assert!(h.app.session.slideshow.random_order, "the checkbox goes through slideshow.playback"); h.settle(SETTLE); } +#[test] +fn a_click_on_a_control_scrolled_out_of_the_settings_page_is_refused() { + // A compact window leaves the lower controls of the Slideshow settings page outside the + // scrolled page room, but they are still laid out and registered. + let mut h = grid_sized([1000.0, 560.0]); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.settle(SETTLE), "the settings page settles"); + let target = latest_widget_rect(&h, "check:settings.slideshowRandom").expect("the checkbox is registered"); + let hit = crate::control::widget_hit(&h.app, "check:settings.slideshowRandom").expect("the checkbox has an egui hit owner"); + assert!( + !crate::control::widget_target_visible(&h.view.ctx, Some(hit), target.center()), + "the random-order checkbox is clipped out of the page: {target:?}" + ); + // Its centre is clipped out, so egui would not hit it. Clicking by id must fail closed rather + // than pressing whatever is at those coordinates. + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], false, "{r}"); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains("check:settings.slideshowRandom"), "{r}"); + assert!(error.contains("nothing was retargeted"), "{r}"); + assert!(!h.app.session.slideshow.random_order, "the clipped control was not clicked"); +} + #[test] fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { let mut h = grid(); @@ -262,31 +439,77 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { h.settle(SETTLE); } -/// Scroll the Settings ▸ Slideshow page until widget `id` is in view (in the middle of the room -/// for the page): the sections are taller than the dialog. (The slide preview and its choice are pinned -/// above the page and are in view already.) -fn reveal(h: &mut Headless, id: &str) { - let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r); - for _ in 0..40 { - h.step(); - let room = find(h, "settings:pageRoom").expect("the Slideshow settings are open"); - // (the preview and its Photo / Intro / Ending choice are pinned above the page: always in - // view, never scrolled to) - let pinned = id == "slideshow:preview" || id.starts_with("button:settingsSlideshowPreviewShows-"); - if pinned && find(h, id).is_some_and(|w| w.bottom() <= room.top()) { +/// The latest registry entry for `id`, as [`crate::control::widget_rect`] looks it up (the last +/// registered rect wins). +fn latest_widget_rect(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn in_page_room(room: egui::Rect, widget: egui::Rect) -> bool { + widget.center().y >= room.top() + 10.0 && widget.center().y <= room.bottom() - 10.0 +} + +/// egui 0.37 smooths wheel deltas over several frames (and ends the action after a short idle). +/// Do not measure or click until the Settings page has really stopped scrolling. +fn wait_for_scroll_settled(h: &mut Headless) { + for _ in 0..600 { + if !h.view.ctx.input(|i| i.is_scrolling()) { return; } - let dy = match find(h, id) { - Some(w) if w.center().y >= room.top() + 10.0 && w.center().y <= room.bottom() - 10.0 => { - // (in view and still: the page eases to where it was scrolled to) - for _ in 0..3 { - h.step(); - } - if find(h, id).is_some_and(|now| (now.center().y - w.center().y).abs() < 0.5) { - return; - } - continue; + h.step(); + } + panic!("the Settings page kept scrolling"); +} + +/// The widget's latest rect once the whole rect (not just its centre) has stayed unchanged for +/// three completed passes. A pinned widget is allowed above the page; a page widget must still be +/// inside the page's room. +fn settled_revealed(h: &mut Headless, id: &str, pinned: bool) -> egui::Rect { + let mut last = None; + let mut stable = 0; + for _ in 0..240 { + h.step(); + let Some(now) = latest_widget_rect(h, id) else { + last = None; + stable = 0; + continue; + }; + let room = latest_widget_rect(h, "settings:pageRoom").expect("the Slideshow settings are open"); + let in_place = if pinned { now.bottom() <= room.top() } else { in_page_room(room, now) }; + if !in_place || h.view.ctx.input(|i| i.is_scrolling()) { + last = Some(now); + stable = 1; + continue; + } + if last == Some(now) { + stable += 1; + if stable >= 3 { + return now; } + } else { + stable = 1; + } + last = Some(now); + } + panic!("{id} never settled in view"); +} + +/// Scroll the Settings ▸ Slideshow page until widget `id` is in view (in the middle of the room +/// for the page): the sections are taller than the dialog. (The slide preview and its choice are pinned +/// above the page and are in view already.) Returns the settled rect that the click will use. +fn reveal(h: &mut Headless, id: &str) -> egui::Rect { + // (the preview and its Photo / Intro / Ending choice are pinned above the page: always in + // view, never scrolled to) + let pinned = id == "slideshow:preview" || id.starts_with("button:settingsSlideshowPreviewShows-"); + for _ in 0..40 { + h.step(); + wait_for_scroll_settled(h); + let room = latest_widget_rect(h, "settings:pageRoom").expect("the Slideshow settings are open"); + let in_view = latest_widget_rect(h, id).is_some_and(|w| if pinned { w.bottom() <= room.top() } else { in_page_room(room, w) }); + if in_view { + return settled_revealed(h, id, pinned); + } + let dy = match latest_widget_rect(h, id) { Some(w) => (room.center().y - w.center().y).clamp(-500.0, 500.0), None => -300.0, }; @@ -294,9 +517,7 @@ fn reveal(h: &mut Headless, id: &str) { assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.scroll", json!({"dy": dy}), T); assert_eq!(r["ok"], true, "{r}"); - for _ in 0..8 { - h.step(); - } + wait_for_scroll_settled(h); } panic!("{id} never came into view"); } @@ -311,7 +532,9 @@ fn a_composed_slide_shows_during_playback() { let mut h = grid(); let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"})); assert_eq!(r["marginTop"], 0.1); - run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + // This checks composed pixels and explicit navigation. Rendering can take enough + // headless frames to pass any automatic interval before the next slide is ready. + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "manual": true})); let current = photo_up(&h); assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); h.step(); @@ -328,8 +551,10 @@ fn a_composed_slide_shows_during_playback() { // the next slide is composed too, and stepping shows it let next = play(&h).upcoming(1)[0]; assert!(h.step_until(SETTLE, |h| has_slide(h, next))); + assert_eq!(photo_up(&h), current, "rendering ahead keeps the inspected slide up"); run(&mut h, "slideshow.next", json!({})); h.step(); + assert_eq!(play(&h).current(), next, "navigation shows the prepared slide"); assert_eq!(painted(&h.paint(), 5, 5), [255, 0, 0]); h.settle(SETTLE); } @@ -338,10 +563,13 @@ fn a_composed_slide_shows_during_playback() { fn the_default_layout_plays_the_photo_as_before() { let mut h = grid(); assert!(h.app.session.slide_layout.is_plain()); - run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + // Inspect one photo's aspect and backdrop without automatic playback advancing + // the simulated clock past it while the asynchronous renderer catches up. + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "manual": true})); let current = photo_up(&h); assert!(h.step_until(SETTLE, |h| has_slide(h, current))); h.step(); + assert_eq!(photo_up(&h), current, "the inspected photo stays up while rendering"); // the photo's own render (its aspect, not the screen's), fitted on black, as before let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap(); let aspect = h.app.session.slide_photo_aspect(current).unwrap(); diff --git a/crates/ui-egui/src/tests_solo_sections.rs b/crates/ui-egui/src/tests/solo_sections.rs similarity index 100% rename from crates/ui-egui/src/tests_solo_sections.rs rename to crates/ui-egui/src/tests/solo_sections.rs diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests/switch_library.rs similarity index 95% rename from crates/ui-egui/src/tests_switch_library.rs rename to crates/ui-egui/src/tests/switch_library.rs index 3e1ce6e37..e2e4f4d11 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests/switch_library.rs @@ -75,6 +75,18 @@ fn switching_libraries_dismisses_the_previous_lightroom_result() { let _ = std::fs::remove_dir_all(dir); } +/// Photo IDs are local to a library: the brief info overlay's last photo is forgotten, so a photo +/// in the new library with the same ID shows the info again. +#[test] +fn switching_libraries_restarts_the_brief_info_overlay() { + let dir = temp("switch-brief-info"); + let mut app = app_in(&dir); + app.ui.brief_info = crate::state::BriefInfo { photo: Some(lightcraft_catalog::PhotoId(1)), since: 5.0, shown: true }; + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Second"})).unwrap(); + assert_eq!(app.ui.brief_info, crate::state::BriefInfo::default()); + let _ = std::fs::remove_dir_all(dir); +} + fn recent_submenu(app: &LightcraftApp) -> Vec { use crate::menubar::MenuNode; let bar = crate::menubar::menu_bar(app); diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests/sync.rs similarity index 100% rename from crates/ui-egui/src/tests_sync.rs rename to crates/ui-egui/src/tests/sync.rs diff --git a/crates/ui-egui/src/tests_tallies.rs b/crates/ui-egui/src/tests/tallies.rs similarity index 100% rename from crates/ui-egui/src/tests_tallies.rs rename to crates/ui-egui/src/tests/tallies.rs diff --git a/crates/ui-egui/src/tests_temp_slider.rs b/crates/ui-egui/src/tests/temp_slider.rs similarity index 100% rename from crates/ui-egui/src/tests_temp_slider.rs rename to crates/ui-egui/src/tests/temp_slider.rs diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests/titlebar.rs similarity index 87% rename from crates/ui-egui/src/tests_titlebar.rs rename to crates/ui-egui/src/tests/titlebar.rs index 36598190c..8d40f212e 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests/titlebar.rs @@ -169,3 +169,25 @@ fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() { assert!(!h.app.window_is_fullscreen); assert_eq!(widget(&h, "icon:sidebar").left(), windowed, "leaving full screen restores the room for the traffic lights"); } + +// Scenario: a panel floating over the bar keeps a drag on it from moving the window (macOS's +// title-bar band would otherwise take the drag); a bar control does too +#[test] +fn a_panel_floating_over_the_bar_keeps_the_window_from_moving() { + let mut h = integrated(); + let bar = widget(&h, "region:titlebar"); + let empty = empty_spot(&h); + let hover = |h: &mut Headless, p: egui::Pos2| { + h.events.push(egui::Event::PointerMoved(p)); + play(h); + craft_window::title_bar::window_movable(&h.view.ctx) + }; + assert!(hover(&mut h, empty), "the empty bar moves the window"); + let help = widget(&h, "icon:help").center(); + assert!(!hover(&mut h, help), "a bar control"); + let params = json!({"operation": "float", "panel": "tools", "rect": [bar.left(), bar.top() + 4.0, 330.0, 500.0]}); + let r = h.request("engine.execute", json!({"command": "ui.dock", "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + play(&mut h); + assert!(!hover(&mut h, empty), "the floating panel covers the empty bar there"); +} diff --git a/crates/ui-egui/src/tests_tool_overlay.rs b/crates/ui-egui/src/tests/tool_overlay.rs similarity index 100% rename from crates/ui-egui/src/tests_tool_overlay.rs rename to crates/ui-egui/src/tests/tool_overlay.rs diff --git a/crates/ui-egui/src/tests_unsaved.rs b/crates/ui-egui/src/tests/unsaved.rs similarity index 100% rename from crates/ui-egui/src/tests_unsaved.rs rename to crates/ui-egui/src/tests/unsaved.rs diff --git a/crates/ui-egui/src/tests_view_cost.rs b/crates/ui-egui/src/tests/view_cost.rs similarity index 100% rename from crates/ui-egui/src/tests_view_cost.rs rename to crates/ui-egui/src/tests/view_cost.rs diff --git a/crates/ui-egui/src/tests_wb_selector.rs b/crates/ui-egui/src/tests/wb_selector.rs similarity index 100% rename from crates/ui-egui/src/tests_wb_selector.rs rename to crates/ui-egui/src/tests/wb_selector.rs diff --git a/crates/ui-egui/src/tests_zoom.rs b/crates/ui-egui/src/tests/zoom.rs similarity index 70% rename from crates/ui-egui/src/tests_zoom.rs rename to crates/ui-egui/src/tests/zoom.rs index 580b70615..ebe3ecd22 100644 --- a/crates/ui-egui/src/tests_zoom.rs +++ b/crates/ui-egui/src/tests/zoom.rs @@ -41,6 +41,73 @@ fn size_of(h: &Headless) -> (f32, f32) { (p.width as f32, p.height as f32) } +/// Keep an old same-photo texture on screen while the replacement is deliberately held. +/// Geometry must follow the edit immediately, independently of renderer throughput. +#[test] +fn a_pending_crop_or_rotation_does_not_reuse_the_old_textures_aspect() { + use crate::render::{RenderOffload, Slot, Tex, geometry_look}; + use crate::state::{RightPanel, Zoom}; + + struct Held; + impl RenderOffload for Held { + fn try_start(&mut self, _: Slot, job: lightcraft_engine::RenderJob) -> Option { + Some(job) + } + fn finished(&mut self) -> Vec<(Slot, lightcraft_engine::media::RenderResult, f64)> { + Vec::new() + } + } + + let fixture = crate::tests_fixtures::SyntheticLibrary::new("zoom-stale-geometry"); + let mut h = Headless::new(fixture.app(), [1400.0, 900.0], 1.0); + h.app.renderer.set_offload(Box::new(Held)); + let response = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(response["ok"], true, "{response}"); + h.step(); + h.app.ui.zoom = Zoom::Percent(100.0); + let id = h.app.session.active().expect("fixture photo"); + let old_look = geometry_look(&h.app.session.develop_of(id).expect("settings")); + let tex = h.view.ctx.load_texture("old loupe", egui::ColorImage::filled([4, 3], egui::Color32::BLACK), Default::default()); + h.app.renderer.textures.insert( + Slot::Main, + Tex { look: Some((old_look, true)), key: 0, photo: id, tex, size: [64, 48], histogram: None, ms: 0.0, quick: None, pixels: None }, + ); + edit(&mut h, |d| d.crop.geometry.rect = lightcraft_geom::Rect::new(0.25, 0.2, 0.75, 0.2 + 1.0 / 3.0)); + for (rotate, expected) in [(false, egui::vec2(32.0, 16.0)), (true, egui::vec2(16.0, 32.0))] { + if rotate { + edit(&mut h, |d| { + d.orientation = lightcraft_geom::Orientation::Rotate90; + // Crop coordinates live in the oriented frame, as in library.rotateRight. + d.crop.geometry.rect = lightcraft_geom::Orientation::Rotate90.map_norm_rect(d.crop.geometry.rect); + }); + } + h.app.ui.zoom_anim = false; + h.step(); + let shown = h.app.image_rect.expect("loupe geometry"); + assert!((shown.size() - expected).abs().max_elem() < 0.01, "pending edit: {shown:?}, expected {expected:?}"); + assert_eq!(h.app.renderer.textures[&Slot::Main].look, Some((old_look, true)), "old pixels remain visible"); + assert!(h.app.renderer.is_pending(Slot::Main), "the edited render is still pending"); + } + // Crop mode shows the full oriented image; leaving it must immediately restore the crop. + h.app.ui.right = RightPanel::Crop; + h.app.ui.zoom_anim = false; + h.step(); + assert!((h.app.image_rect.expect("crop tool geometry").size() - egui::vec2(48.0, 64.0)).abs().max_elem() < 0.01); + h.app.ui.right = RightPanel::None; + h.app.ui.zoom_anim = false; + h.step(); + assert!((h.app.image_rect.expect("restored crop geometry").size() - egui::vec2(16.0, 32.0)).abs().max_elem() < 0.01); + // An actual current-settings render can still correct metadata that differs from the + // decoded image (unsupported RAWs may provide only a differently cropped embedded JPEG). + let current = geometry_look(&h.app.session.develop_of(id).expect("settings")); + let tex = h.app.renderer.textures.get_mut(&Slot::Main).expect("old texture"); + tex.look = Some((current, true)); + tex.size = [16, 48]; + h.step(); + let shown = h.app.image_rect.expect("current render geometry"); + assert!((shown.size() - egui::vec2(16.0, 48.0)).abs().max_elem() < 0.01, "current pixels correct metadata: {shown:?}"); +} + // Given an uncropped photo, 100 % is its pixel size #[test] fn an_uncropped_photo_at_100_is_its_pixel_size() { diff --git a/crates/ui-egui/src/tests_zoom_keys.rs b/crates/ui-egui/src/tests/zoom_keys.rs similarity index 100% rename from crates/ui-egui/src/tests_zoom_keys.rs rename to crates/ui-egui/src/tests/zoom_keys.rs diff --git a/crates/ui-egui/src/tests_drop_import.rs b/crates/ui-egui/src/tests_drop_import.rs deleted file mode 100644 index 837d60c76..000000000 --- a/crates/ui-egui/src/tests_drop_import.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Files dragged from the file manager onto the window: while they hover, the window says what -//! dropping will do; dropping photos or folders opens the import review (unless Settings ▸ Import -//! says to import dropped files directly); a drop while a dialog is open is refused. - -use std::time::Duration; - -use serde_json::json; - -use crate::headless::Headless; -use crate::state::Dialog; -use crate::{LightcraftApp, Services}; - -const T: Duration = Duration::from_secs(20); -const SETTLE: Duration = Duration::from_secs(120); - -/// A scratch folder that goes away with the test, however it ends. -struct Scratch(std::path::PathBuf); - -impl Scratch { - fn new(tag: &str) -> Scratch { - let dir = std::env::temp_dir().join(format!("lc-ui-drop-import-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - Scratch(dir) - } - fn path(&self, rel: &str) -> String { - self.0.join(rel).to_string_lossy().to_string() - } -} - -impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn write_png(path: &str, seed: u8) { - let (w, h) = (24usize, 16usize); - let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; - let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap(); - std::fs::write(path, bytes).unwrap(); -} - -/// An empty library, a folder `trip` with two photos, a window big enough for the overlay. -fn app(dir: &Scratch) -> Headless { - write_png(&dir.path("trip/a.png"), 1); - write_png(&dir.path("trip/b.png"), 2); - let session = lightcraft_engine::Session::new().with_fs(); - let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0); - for _ in 0..4 { - h.step(); - } - h -} - -fn drop_files(h: &mut Headless, paths: &[String], hover: bool) { - let r = h.request("ui.dropFiles", json!({"paths": paths, "hover": hover}), T); - assert_eq!(r["ok"], true, "{r}"); - for _ in 0..3 { - h.step(); - } -} - -fn overlay(h: &Headless) -> bool { - h.app.widgets.iter().any(|(w, _)| w == "dropOverlay") -} - -fn said(h: &Headless, word: &str) -> bool { - h.painted_text().iter().any(|t| t.contains(word)) -} - -fn photos(h: &Headless) -> usize { - h.app.session.catalog.photos().count() -} - -#[test] -fn hovering_photos_says_they_will_be_reviewed_and_lets_go_when_they_leave() { - let dir = Scratch::new("hover"); - let mut h = app(&dir); - assert!(!overlay(&h), "nothing is dragged over the window"); - drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], true); - assert!(overlay(&h), "the window shows where to drop"); - assert!(said(&h, "review 2 items"), "{:?}", h.painted_text()); - drop_files(&mut h, &[], true); - assert!(!overlay(&h), "the files left the window"); -} - -#[test] -fn hovering_presets_says_they_will_be_imported_as_presets() { - let dir = Scratch::new("hover-presets"); - let mut h = app(&dir); - std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); - drop_files(&mut h, &[dir.path("warm.lcpreset")], true); - assert!(overlay(&h)); - assert!(said(&h, "1 preset"), "{:?}", h.painted_text()); - assert!(!said(&h, "review"), "presets are not reviewed"); -} - -#[test] -fn hovering_unknown_files_says_only_that_dropping_imports() { - let dir = Scratch::new("hover-unknown"); - let mut h = app(&dir); - drop_files(&mut h, &[String::new()], true); - assert!(overlay(&h)); - assert!(said(&h, "Drop to import"), "{:?}", h.painted_text()); -} - -#[test] -fn dropping_a_folder_opens_the_import_review_and_imports_nothing_yet() { - let dir = Scratch::new("review"); - let mut h = app(&dir); - drop_files(&mut h, &[dir.path("trip")], false); - assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens"); - let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.candidates.len(), 2); - assert_eq!(photos(&h), 0, "nothing is imported until the review is confirmed"); - assert!(!overlay(&h), "the files were let go"); -} - -#[test] -fn dropping_imports_directly_when_settings_say_so() { - let dir = Scratch::new("direct"); - let mut h = app(&dir); - h.app.ui.settings.import_dropped_directly = true; - drop_files(&mut h, &[dir.path("trip")], false); - assert!(h.step_until(SETTLE, |h| photos(h) == 2 && h.app.import.is_none()), "both photos arrive"); - assert!(h.app.ui.dialog.is_none(), "no review"); -} - -#[test] -fn a_drop_while_a_dialog_is_open_leaves_the_dialog_and_imports_nothing() { - let dir = Scratch::new("dialog"); - let mut h = app(&dir); - h.app.ui.dialog = Some(Dialog::WhatsNew); - drop_files(&mut h, &[dir.path("trip/a.png")], true); - assert!(overlay(&h), "the window still answers the hover"); - assert!(said(&h, "Close the open dialog"), "{:?}", h.painted_text()); - drop_files(&mut h, &[dir.path("trip")], false); - for _ in 0..30 { - h.step(); - } - assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the open dialog stays"); - assert!(h.app.scan.is_none() && h.app.import.is_none(), "no scan or import started"); - assert_eq!(photos(&h), 0); - assert!(h.app.ui.toast.is_some(), "the person is told why"); -} - -/// The scan of a dropped folder finishing while another dialog is open (the face model's licence, Settings…) -/// keeps that dialog; the review opens, with the scan's photos, once it is closed. -#[test] -fn a_finished_scan_waits_for_the_open_dialog_before_opening_the_review() { - let dir = Scratch::new("waits"); - let mut h = app(&dir); - crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); - h.app.ui.dialog = Some(Dialog::WhatsNew); - assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); - for _ in 0..10 { - h.step(); - } - assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the dialog the person had open stays"); - h.app.ui.dialog = None; - assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens once the dialog is closed"); - let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.candidates.len(), 2, "the scan's photos were kept"); -} - -/// A scan that finishes behind Settings, which then opens another library: the review of the first -/// library's scan doesn't open over the second one once Settings closes, and nothing is imported into it. -#[test] -fn a_waiting_review_is_dropped_when_another_library_opens() { - let dir = Scratch::new("switch"); - let mut h = app(&dir); - crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); - h.app.ui.dialog = Some(Dialog::Settings { tab: "general".into() }); - assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); - h.step(); - let stale = h.app.review_pending.clone().expect("the review waits behind Settings"); - h.app.run("app.openLibrary", json!({"path": dir.path("other")})).unwrap(); - h.app.ui.dialog = None; - for _ in 0..10 { - h.step(); - } - assert!(h.app.ui.dialog.is_none(), "no review from the other library opens"); - assert!(h.app.review_pending.is_none()); - // a review confirmed after the switch (an agent holding it) imports nothing either - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &stale).is_err()); - for _ in 0..10 { - h.step(); - } - assert!(h.app.import.is_none(), "nothing is imported"); - assert_eq!(photos(&h), 0); -} - -/// A review waiting behind another dialog counts as an import in progress: a direct drop meanwhile is -/// refused, and the review's OK never replaces an import that runs when it is confirmed. -#[test] -fn a_waiting_review_never_replaces_a_running_import() { - let dir = Scratch::new("pending-running"); - let mut h = app(&dir); - h.app.ui.settings.import_dropped_directly = true; - write_png(&dir.path("more/c.png"), 3); - crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); - h.app.ui.dialog = Some(Dialog::WhatsNew); - assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); - h.step(); - assert!(h.app.review_pending.is_some(), "the review waits behind the dialog"); - assert!(crate::import::start_paths(&mut h.app, vec![dir.path("more")]).is_err(), "no second import starts"); - assert!(h.app.import.is_none()); - h.app.ui.dialog = None; - h.step(); - let Some(review) = h.app.ui.dialog.clone() else { panic!("the review opens once the dialog is closed") }; - assert!(matches!(review, Dialog::Import { .. })); - // an import that started anyway (a Local folder browse, say) is the one that runs - h.app.import = Some(crate::import::ImportTask::new(vec![dir.path("more")], json!({"mode": "add"}), 0, false)); - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &review).is_err(), "the review's OK is refused"); - assert_eq!(h.app.import.as_ref().map(|t| t.total), Some(1), "the running import stays"); - assert!(h.step_until(SETTLE, |h| h.app.import.is_none()), "it runs to the end"); - assert_eq!(photos(&h), 1, "only the running import's photo arrives"); -} - -/// A drop while an import runs would open a review whose OK replaces the running import: it is -/// refused instead, and the card says so. -#[test] -fn a_drop_while_an_import_runs_is_refused() { - let dir = Scratch::new("running"); - let mut h = app(&dir); - write_png(&dir.path("more/c.png"), 3); - let _scan = crate::import::stall_scan(&mut h.app); - drop_files(&mut h, &[dir.path("more/c.png")], true); - assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); - assert!(!said(&h, "review"), "{:?}", h.painted_text()); - drop_files(&mut h, &[dir.path("more")], false); - for _ in 0..30 { - h.step(); - } - assert!(h.app.ui.dialog.is_none(), "no review opened"); - assert!(h.app.scan.is_some() && h.app.import.is_none(), "the running scan was left alone and nothing else started"); - assert!(h.app.ui.toast.is_some(), "the person is told why"); - assert_eq!(photos(&h), 0); -} - -/// An import that is running stays the one that runs: a direct drop doesn't start a second. -#[test] -fn a_direct_drop_while_an_import_runs_is_refused_too() { - let dir = Scratch::new("running-direct"); - let mut h = app(&dir); - h.app.ui.settings.import_dropped_directly = true; - write_png(&dir.path("more/c.png"), 3); - let _scan = crate::import::stall_scan(&mut h.app); - drop_files(&mut h, &[dir.path("more")], false); - assert!(h.app.import.is_none(), "no second import"); - assert!(h.app.ui.toast.is_some()); -} - -/// A Local folder browse scan counts too: a review-first drop would be rejected by `open`, and a -/// direct drop would be replaced when the browse completes. -#[test] -fn a_drop_while_a_browse_scan_runs_is_refused_in_both_modes() { - for direct in [false, true] { - let dir = Scratch::new("running-browse"); - let mut h = app(&dir); - h.app.ui.settings.import_dropped_directly = direct; - write_png(&dir.path("more/c.png"), 3); - let _scan = crate::import::stall_scan_of(&mut h.app, true); - drop_files(&mut h, &[dir.path("more/c.png")], true); - assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); - drop_files(&mut h, &[dir.path("more")], false); - for _ in 0..30 { - h.step(); - } - assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "direct={direct}: nothing started"); - assert!(h.app.scan.is_some(), "the browse was left alone"); - assert!(h.app.ui.toast.is_some(), "the person is told why"); - } -} - -/// While a Lightroom catalog import is pending the drop is refused, and the card says so instead of -/// promising a review. -#[test] -fn hovering_photos_while_a_lightroom_import_is_pending_says_they_will_be_refused() { - let dir = Scratch::new("lightroom"); - let mut h = app(&dir); - let _task = crate::lightroom_import::stall_import(&mut h.app); - drop_files(&mut h, &[dir.path("trip")], true); - assert!(said(&h, "Finish Lightroom catalog import"), "{:?}", h.painted_text()); - assert!(!said(&h, "review"), "{:?}", h.painted_text()); - drop_files(&mut h, &[dir.path("trip")], false); - assert!(h.app.ui.dialog.is_none(), "no review opened"); - assert!(h.app.ui.toast.is_some(), "the person is told why"); - assert_eq!(photos(&h), 0); -} - -/// A script is not told `ok` for the part of a `paths` list that was strings. -#[test] -fn drop_files_with_a_non_string_path_is_rejected_and_drops_nothing() { - let dir = Scratch::new("badpaths"); - let mut h = app(&dir); - let r = h.request("ui.dropFiles", json!({"paths": [dir.path("trip"), 7]}), T); - assert_eq!(r["ok"], false, "{r}"); - for _ in 0..30 { - h.step(); - } - assert!(h.app.ui.dialog.is_none(), "nothing was dropped"); - assert_eq!(photos(&h), 0); -} - -/// No library open yet (the window of a library that failed to open, or the launch list): the -/// card says the photos wait, and a drop keeps them for the library chosen instead of importing -/// them into the session that library replaces. -#[test] -fn photos_dropped_with_no_library_open_wait_for_one() { - let dir = Scratch::new("no-library"); - let mut h = app(&dir); - h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new(dir.path("Gone"), "not found")); - std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); - drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], true); - assert!(said(&h, "once a library is open"), "{:?}", h.painted_text()); - assert!(said(&h, "drop the presets again") && !said(&h, "1 preset"), "{:?}", h.painted_text()); - drop_files(&mut h, &[], true); - drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], false); - assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(2)); - assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "no review, no import into the temporary session"); - assert_eq!(photos(&h), 0); - // a preset belongs to a library: refused, not imported into the session the library replaces - h.app.ui.toast = None; - drop_files(&mut h, &[dir.path("warm.lcpreset")], false); - let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); - assert!(toast.contains("drop the presets again"), "{toast}"); -} diff --git a/crates/ui-egui/src/tests_library_problem.rs b/crates/ui-egui/src/tests_library_problem.rs deleted file mode 100644 index 10783ff15..000000000 --- a/crates/ui-egui/src/tests_library_problem.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Issue #100: a library that can't be opened at launch is never replaced by a silent in-memory -//! demo session. The window says why and offers Try Again / Choose Another Library… / Continue -//! Without Saving / Quit; a temporary session shows a banner and never writes to the library. - -use std::time::Duration; - -use serde_json::json; - -use crate::headless::Headless; -use crate::panels::library_problem::LibraryProblem; -use crate::{LightcraftApp, Services}; - -const T: Duration = Duration::from_secs(20); - -fn has(h: &Headless, id: &str) -> bool { - h.app.widgets.iter().any(|(w, _)| w == id) -} - -#[test] -fn unopenable_library_asks_instead_of_running_a_demo() { - let dir = std::env::temp_dir().join(format!("lc-ui-libproblem-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let lib = dir.join("lib"); - std::fs::create_dir_all(&lib).unwrap(); - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - let photo = dir.join("a.png"); - std::fs::write(&photo, png).unwrap(); - - // another program has the library open: the launch fails like the desktop host's would - let held = lightcraft_catalog::LibraryLock::acquire(&lib, "another LightCraft").unwrap(); - let mut session = lightcraft_engine::Session::new().with_fs(); - let err = session.open_library(&lib, true).unwrap_err().to_string(); - let png = |img: &lightcraft_raster::Rgba8| { - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() - }; - let mut app = LightcraftApp::new( - session, - Services { - png: Some(Box::new(png)), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - ..Default::default() - }, - ); - app.library_problem = - Some(LibraryProblem { pending_import: vec![photo.to_string_lossy().to_string()], ..LibraryProblem::new(lib.to_string_lossy(), err) }); - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - h.step(); - h.step(); - assert!(h.app.session.catalog.is_empty(), "no demo photos"); - for b in ["button:libraryRetry", "button:libraryChoose", "button:libraryTemporary", "button:libraryQuit"] { - assert!(has(&h, b), "{b}"); - } - if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { - let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("dialog.png").to_string_lossy(), "headless": true}), T); - assert_eq!(r["ok"], true, "{r}"); - } - let inspect = h.request("ui.inspect", json!({}), T); - let p = &inspect["result"]["libraryProblem"]; - assert!(p["error"].as_str().unwrap().contains("already open in another LightCraft"), "{p}"); - assert_eq!(p["temporarySession"], false); - - // still locked: Try Again keeps the window, with the error - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); - h.step(); - assert!(h.app.session.library.is_none()); - assert!(has(&h, "button:libraryRetry")); - - // Continue Without Saving: a banner, the command-line photo in the temporary session, and - // nothing written into the library folder - let before: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect(); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryTemporary"}), T)["ok"], true); - h.step(); - h.step(); - assert!(!has(&h, "button:libraryRetry")); - assert!(has(&h, "indicator:temporarySession")); - assert!(h.app.session.library.is_none()); - // imported on the import worker (issue #374: never synchronously on the UI thread) - h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1); - assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the temporary session"); - if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { - let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("banner.png").to_string_lossy(), "headless": true}), T); - assert_eq!(r["ok"], true, "{r}"); - } - h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 3}}), T); - let after: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect(); - assert_eq!(before, after, "the temporary session never writes to the library"); - - // the other program quits; Open Library… → Try Again opens it, the banner goes away - drop(held); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryReopen"}), T)["ok"], true); - h.step(); - assert!(has(&h, "button:libraryRetry")); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); - h.step(); - h.step(); - assert!(h.app.session.library.is_some()); - assert!(h.app.library_problem.is_none()); - assert!(!has(&h, "indicator:temporarySession") && !has(&h, "button:libraryRetry")); - assert!(h.app.session.catalog.is_empty(), "an opened (new) library is never seeded with demo photos here"); - drop(h); - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs deleted file mode 100644 index aed99f4d8..000000000 --- a/crates/ui-egui/src/text_field.rs +++ /dev/null @@ -1,789 +0,0 @@ -//! A text field that behaves the same everywhere in the app: right-click for Cut, Copy, Paste and -//! Select All; Esc gives back the text from before the edit; Return or leaving the field ends the -//! edit; optionally everything is selected when it takes focus, for fields that are retyped whole. -//! -//! It knows nothing of the app's documents or commands, only the UI's plumbing (the automation -//! registry, translations, shortcut labels). The caller owns the text and learns how an edit -//! ended from [`TextFieldResponse::ending`]; what an ending means (save, apply, close) is the -//! caller's. Each field's widget id is unique on screen: its state is keyed by it. -//! -//! New text boxes use it rather than `egui::TextEdit`. -//! -//! The keyboard's ⌘X / ⌘C / ⌘V / ⌘A and undo are egui's own, and the app's shortcuts step aside -//! while a field has focus (`shortcuts::handle`, the native menu's `yields_to_text`). - -use egui::{Response, Ui}; - -/// How an edit ended. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Ending { - /// Return in a one-line field: the text is meant. - Return, - /// The focus went elsewhere (a click outside, Tab): the text is kept. - Away, - /// Esc: the text is back to what it was before the edit. - Escape, -} - -pub struct TextFieldResponse { - pub response: Response, - /// Set on the frame an edit ends. - pub ending: Option, - /// An edit is under way: the field has the focus, or its menu does. - pub editing: bool, -} - -impl TextFieldResponse { - /// The edit ended with the text kept (Return or leaving the field). - pub fn committed(&self) -> bool { - matches!(self.ending, Some(Ending::Return | Ending::Away)) - } - - /// Esc ended the edit and the text is back as it was. - pub fn cancelled(&self) -> bool { - self.ending == Some(Ending::Escape) - } -} - -/// A one-line or multi-line text field, registered under a widget id for the control channel. -pub struct TextField<'a> { - widget: &'a str, - text: &'a mut String, - multiline: bool, - hint: Option, - label: Option, - width: Option, - select_on_focus: bool, - begin_edit: bool, - frame: bool, - font: Option, - color: Option, - align: Option, - margin: Option, - rows: Option, -} - -/// The egui id of the field registered as `widget`, to give it the focus (⌘F to search). -pub fn id(widget: &str) -> egui::Id { - egui::Id::new("lc-text-field").with(widget) -} - -/// Whether the field had an unfinished edit on the immediately preceding egui pass. -pub(crate) fn was_editing(ctx: &egui::Context, widget: &str) -> bool { - let pass = ctx.cumulative_pass_nr(); - ctx.data(|d| d.get_temp::(id(widget).with("memo")).is_some_and(|memo| memo.before.is_some() && memo.pass.checked_add(1) == Some(pass))) -} - -impl<'a> TextField<'a> { - /// A one-line field (Return ends the edit) registered as `widget`. - pub fn singleline(widget: &'a str, text: &'a mut String) -> Self { - TextField { - widget, - text, - multiline: false, - hint: None, - label: None, - width: None, - select_on_focus: false, - begin_edit: false, - frame: true, - font: None, - color: None, - align: None, - margin: None, - rows: None, - } - } - - /// A multi-line field: Return starts a new line; leaving the field ends the edit. - pub fn multiline(widget: &'a str, text: &'a mut String) -> Self { - TextField { multiline: true, ..Self::singleline(widget, text) } - } - - /// Grey text shown while the field is empty. - pub fn hint(mut self, hint: impl Into) -> Self { - self.hint = Some(hint.into()); - self - } - - /// The name a screen reader announces for the field (its visible caption); the hint - /// stands in when there is none. - pub fn label(mut self, label: impl Into) -> Self { - self.label = Some(label.into()); - self - } - - pub fn width(mut self, width: f32) -> Self { - self.width = Some(width); - self - } - - /// How many lines a multi-line field shows. - pub fn rows(mut self, rows: usize) -> Self { - self.rows = Some(rows); - self - } - - /// Draw the field's own box (on by default); off where the caller draws one around it. - pub fn frame(mut self, on: bool) -> Self { - self.frame = on; - self - } - - pub fn font(mut self, font: egui::FontId) -> Self { - self.font = Some(font); - self - } - - pub fn text_color(mut self, color: egui::Color32) -> Self { - self.color = Some(color); - self - } - - pub fn horizontal_align(mut self, align: egui::Align) -> Self { - self.align = Some(align); - self - } - - pub fn margin(mut self, margin: egui::Margin) -> Self { - self.margin = Some(margin); - self - } - - /// Select all the text when the field takes focus, so typing replaces it (names, numbers). - pub fn select_on_focus(mut self, on: bool) -> Self { - self.select_on_focus = on; - self - } - - /// Initialize a newly revealed value editor before its first input is processed. - pub(crate) fn begin_edit(mut self, on: bool) -> Self { - self.begin_edit = on; - self - } - - pub fn show(self, ui: &mut Ui) -> TextFieldResponse { - let id = id(self.widget); - 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 - let pass = ui.ctx().cumulative_pass_nr(); - if memo.pass.checked_add(1) != Some(pass) { - memo = Memo::default(); - } - memo.pass = pass; - if self.begin_edit { - memo.before = Some(self.text.clone()); - memo.select_on_release = false; - ui.memory_mut(|m| m.request_focus(id)); - select_all(ui.ctx(), id, self.text); - } - // a choice from the menu last frame: the field takes the focus back and does it, with - // egui's own handling of the clipboard events (a password is never copied) - if let Some(action) = memo.action.take() { - ui.memory_mut(|m| m.request_focus(id)); - match action { - Action::Cut => ui.input_mut(|i| i.events.push(egui::Event::Cut)), - Action::Copy => ui.input_mut(|i| i.events.push(egui::Event::Copy)), - // only the host can read the clipboard: it answers with a paste event - Action::Paste => ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste), - Action::SelectAll => select_all(ui.ctx(), id, self.text), - Action::Refocus => {} - } - } - 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); - if let Some(hint) = self.hint { - edit = edit.hint_text(hint); - } - if let Some(w) = self.width { - edit = edit.desired_width(w); - } - if !self.frame { - edit = edit.frame(egui::Frame::NONE); - } - if let Some(font) = self.font { - edit = edit.font(font); - } - if let Some(color) = self.color { - edit = edit.text_color(color); - } - if let Some(align) = self.align { - edit = edit.horizontal_align(align); - } - if let Some(margin) = self.margin { - edit = edit.margin(margin); - } - if let Some(rows) = self.rows { - edit = edit.desired_rows(rows); - } - let (escape, enter) = ui.input(|i| (i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Enter))); - // a right-click places the cursor as a left one does: the selection the menu acts on - // would be gone - let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary)); - let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range()); - let mut response = ui.add(edit); - // screen readers: the field by its caption, even once it holds text (egui names it by - // nothing but its contents) - if let Some(name) = name.filter(|n| !n.trim().is_empty()) { - ui.ctx().accesskit_node_builder(response.id, |node| node.set_label(name)); - } - // The menu may take focus on a primary-button release. egui collapses a - // TextEdit selection when focus leaves; retain the range our own menu acts on. - if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) { - let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); - state.cursor.set_char_range(selection); - state.store(ui.ctx(), id); - } - crate::widgets::register(ui.ctx(), self.widget, response.rect); - // egui's own focus: `Response::has_focus` is also false while the window is in the - // background (another app in front), which neither ends nor restarts an edit - let focused = ui.memory(|m| m.has_focus(id)); - // The opening press may place a cursor; keep the original selected, never replacement text. - if self.begin_edit && focused && !response.changed() { - select_all(ui.ctx(), id, self.text); - } - - // an edit not under way (none, or one cut short when the field went away) is forgotten - let in_menu = memo.away || memo.menu_open || memo.action.is_some(); - if !focused && !response.lost_focus() && !in_menu { - memo.before = None; - } - // the start of an edit (not the focus coming back from the menu) - let pressing = ui.input(|i| i.pointer.any_down()); - if focused && memo.before.is_none() { - memo.before = Some(self.text.clone()); - if self.select_on_focus { - // a press that took the focus may go on to drag a selection: wait for its release - if pressing { - memo.select_on_release = true; - } else { - select_all(ui.ctx(), id, self.text); - } - } - } - if memo.select_on_release && !pressing { - memo.select_on_release = false; - // a plain click selects everything; a drag keeps what it selected - if focused && !has_selection(ui.ctx(), id) { - select_all(ui.ctx(), id, self.text); - } - } - let menu_was_open = memo.menu_open; - // a click on a greyed-out item does nothing, as in a native menu: only a choice or a click - // outside closes it (not the right-click that moves the menu within the field, #1877) - memo.menu_open = egui::Popup::context_menu(&response) - .close_behavior(crate::widgets::context_menu_close(&response, egui::PopupCloseBehavior::CloseOnClickOutside)) - .show(|ui| menu(ui, self.widget, id, self.text, &mut memo.action)) - .is_some(); - - let mut ending = None; - let close_menu = |ui: &Ui| egui::Popup::close_id(ui.ctx(), egui::Popup::default_response_id(&response)); - // egui reports a lost focus on two frames: only an edit under way ends - if response.lost_focus() && memo.before.is_some() { - if escape && menu_was_open { - // Esc closes only the menu, as in native menus: the field takes the focus back - memo.action = Some(Action::Refocus); - } else if escape { - if let Some(before) = memo.before.take() - && *self.text != before - { - *self.text = before; - response.mark_changed(); - } - ending = Some(Ending::Escape); - } else if enter && !self.multiline { - ending = Some(Ending::Return); - close_menu(ui); - } else if menu_was_open || memo.menu_open || memo.action.is_some() { - // the click went to the menu: the edit goes on once the menu is done with - memo.away = true; - } else { - ending = Some(Ending::Away); - } - } else if memo.away && escape && memo.action.is_none() { - // Esc with the focus in the menu (Tab took it there) closes only the menu - memo.action = Some(Action::Refocus); - memo.away = false; - } else if memo.away && !memo.menu_open && memo.action.is_none() && !focused { - // the menu closed without a choice: the click outside it left the field - ending = Some(Ending::Away); - } - if focused { - memo.away = false; - } - if ending.is_some() { - memo = Memo { pass, ..Memo::default() }; - } - let editing = ending.is_none() && (focused || memo.away || memo.menu_open || memo.action.is_some()); - ui.data_mut(|d| d.insert_temp(id.with("memo"), memo)); - TextFieldResponse { response, ending, editing } - } -} - -/// What a field remembers between frames. -#[derive(Clone, Default)] -struct Memo { - /// The text when the edit started, for Esc. - before: Option, - /// The context menu was open at the end of the frame. - menu_open: bool, - /// The menu's choice, done next frame. - action: Option, - /// The focus left for the menu: the edit ends if the menu closes without a choice. - away: bool, - /// The field took the focus with select-on-focus under a press: select all on its release, - /// unless it dragged a selection. - select_on_release: bool, - /// The frame (egui pass) the field was last drawn in. - pass: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Action { - Cut, - Copy, - Paste, - SelectAll, - /// Only take the focus back (Esc closed the menu). - Refocus, -} - -fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) { - let mut state = egui::text_edit::TextEditState::load(ctx, id).unwrap_or_default(); - let end = egui::text::CCursor::new(text.chars().count()); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); - state.store(ctx, id); -} - -/// Some text is selected in the field. -fn has_selection(ctx: &egui::Context, id: egui::Id) -> bool { - egui::text_edit::TextEditState::load(ctx, id).and_then(|s| s.cursor.char_range()).is_some_and(|r| !r.is_empty()) -} - -/// The field's context menu: Cut, Copy, Paste, Select All, with the keyboard's shortcuts beside them. -fn menu(ui: &mut Ui, widget: &str, id: egui::Id, text: &str, action: &mut Option) { - let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; - let selected = has_selection(ui.ctx(), id); - let items = [ - (Action::Cut, "cut", "Cut", "Cmd+X", selected), - (Action::Copy, "copy", "Copy", "Cmd+C", selected), - // a browser only pastes from its own paste event, never on request - (Action::Paste, "paste", "Paste", "Cmd+V", !cfg!(target_arch = "wasm32")), - (Action::SelectAll, "selectAll", "Select All", "Cmd+A", !text.is_empty()), - ]; - for (a, key, label, shortcut, enabled) in items { - if a == Action::Paste && cfg!(target_arch = "wasm32") { - continue; - } - if a == Action::SelectAll { - ui.separator(); - } - let button = egui::Button::new(crate::i18n::tr(label)).shortcut_text(crate::menubar::shortcut_text(shortcut, mac)); - let r = ui.add_enabled(enabled, button); - crate::widgets::register(ui.ctx(), format!("{widget}:{key}"), r.rect); - if r.clicked() { - *action = Some(a); - ui.close(); - } - } -} - -#[cfg(test)] -mod tests { - use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect}; - - use super::*; - use crate::headless::HeadlessView; - - const FIELD: &str = "cityField"; - - /// A field alone in a window, with a blank area beside it to click away to. - struct Rig { - view: HeadlessView, - text: String, - select_on_focus: bool, - /// The field is on screen (a dialog that holds it may close). - shown: bool, - /// The app's window has the system's focus (the user may switch to another app). - window_focused: bool, - /// The field said an edit is under way, last frame. - editing: bool, - endings: Vec, - widgets: Vec<(String, Rect)>, - time: f64, - } - - impl Rig { - fn new(text: &str) -> Self { - let mut rig = Rig { - view: HeadlessView::new(), - text: text.to_string(), - select_on_focus: false, - shown: true, - window_focused: true, - editing: false, - endings: vec![], - widgets: vec![], - time: 0.0, - }; - rig.frame(vec![]); - rig - } - - fn frame(&mut self, events: Vec) { - self.time += 1.0 / 60.0; - let mut raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events); - raw.focused = self.window_focused; - let (text, select, shown, endings, editing) = (&mut self.text, self.select_on_focus, self.shown, &mut self.endings, &mut self.editing); - self.view.run(raw, |ui| { - ui.add_space(20.0); - if !shown { - return; - } - let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui); - endings.extend(r.ending); - *editing = r.editing; - }); - self.widgets = crate::widgets::take_registry(&self.view.ctx); - } - - /// A few frames, for menus to open and requests to come back. - fn settle(&mut self) { - for _ in 0..4 { - self.frame(vec![]); - } - } - - fn rect(&self, id: &str) -> Option { - self.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) - } - - fn click_at(&mut self, at: Pos2, button: PointerButton) { - // far apart in time from the last click: two clicks never make a double-click - self.time += 1.0; - let press = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }; - self.frame(vec![Event::PointerMoved(at), press(true)]); - self.frame(vec![press(false)]); - self.settle(); - } - - fn click(&mut self, id: &str, button: PointerButton) { - let r = self.rect(id).unwrap_or_else(|| panic!("no {id} on screen: {:?}", self.widgets)); - self.click_at(r.center(), button); - } - - fn key(&mut self, key: Key, modifiers: Modifiers) { - let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; - self.frame(vec![ev(true), ev(false)]); - self.settle(); - } - - fn type_text(&mut self, text: &str) { - self.frame(vec![Event::Text(text.to_string())]); - self.settle(); - } - - fn focused(&self) -> bool { - self.view.ctx.memory(|m| m.focused()).is_some() - } - - /// Click into the field and select all its text with ⌘A. - fn select_all_by_keys(&mut self) { - self.click(FIELD, PointerButton::Primary); - self.key(Key::A, Modifiers::COMMAND); - } - - /// Right-click the field, then pick `item` from its menu. - fn menu(&mut self, item: &str) { - self.click(FIELD, PointerButton::Secondary); - self.click(&format!("{FIELD}:{item}"), PointerButton::Primary); - } - } - - /// Right-click offers Cut, Copy, Paste and Select All. - #[test] - fn right_click_offers_cut_copy_paste_and_select_all() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Secondary); - for item in ["cut", "copy", "paste", "selectAll"] { - assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: {:?}", rig.widgets); - } - } - - /// Copy in the menu puts the selected text on the clipboard and leaves the field as it was, - /// still being edited. - #[test] - fn copy_in_the_menu_copies_the_selection() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.menu("copy"); - assert_eq!(rig.view.clipboard, "Lisbon"); - assert_eq!(rig.text, "Lisbon"); - assert!(rig.focused(), "still editing"); - assert_eq!(rig.endings, vec![], "using the menu is not leaving the field"); - } - - /// Cut in the menu puts the selected text on the clipboard and takes it out of the field. - #[test] - fn cut_in_the_menu_moves_the_selection_to_the_clipboard() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.menu("cut"); - assert_eq!(rig.view.clipboard, "Lisbon"); - assert_eq!(rig.text, ""); - } - - /// Paste in the menu puts the clipboard's text in place of the selection. - #[test] - fn paste_in_the_menu_replaces_the_selection() { - let mut rig = Rig::new("Lisbon"); - rig.view.clipboard = "Porto".into(); - rig.select_all_by_keys(); - rig.menu("paste"); - assert_eq!(rig.text, "Porto"); - } - - /// Select All in the menu selects the whole text: what is typed next replaces it. - #[test] - fn select_all_in_the_menu_selects_the_whole_text() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Primary); - rig.menu("selectAll"); - rig.type_text("Kyoto"); - assert_eq!(rig.text, "Kyoto"); - } - - /// #1877: a right-click elsewhere in the field while its menu is open moves the menu there - /// (it flashed at the new spot and closed). - #[test] - fn right_clicking_again_moves_the_menu() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Secondary); - let before = rig.rect(&format!("{FIELD}:cut")).expect("the menu"); - let rect = rig.rect(FIELD).unwrap(); - let at = egui::pos2(rect.left() + 10.0, rect.center().y); - rig.click_at(at, PointerButton::Secondary); - let after = rig.rect(&format!("{FIELD}:cut")).unwrap_or_else(|| panic!("the menu is still open: {:?}", rig.widgets)); - assert!(after.left() < before.left(), "the menu moved to the pointer: {after:?} (was {before:?})"); - assert!(rig.endings.is_empty(), "the edit goes on"); - } - - #[test] - fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.click(FIELD, PointerButton::Secondary); - let rect = rig.rect(FIELD).unwrap(); - rig.click_at(egui::pos2(rect.left() + 2.0, rect.center().y), PointerButton::Primary); - let range = egui::text_edit::TextEditState::load(&rig.view.ctx, id(FIELD)).unwrap().cursor.char_range().unwrap(); - assert!(range.is_empty(), "a click back in the field places a caret: {range:?}"); - rig.type_text("X"); - assert_eq!(rig.text, "XLisbon"); - assert!(rig.endings.is_empty(), "dismissing into the field keeps the edit open"); - } - - /// With nothing selected, Cut and Copy are greyed out: clicking one does nothing, as in a native - /// menu, and neither closes the menu nor ends the edit. - #[test] - fn greyed_out_items_do_nothing() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Primary); - rig.click(FIELD, PointerButton::Secondary); - for item in ["cut", "copy"] { - rig.click(&format!("{FIELD}:{item}"), PointerButton::Primary); - assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: the menu is still open"); - } - assert_eq!((rig.text.as_str(), rig.view.clipboard.as_str()), ("Lisbon", "")); - assert_eq!(rig.endings, vec![], "still editing"); - rig.menu("selectAll"); - rig.type_text("Kyoto"); - assert_eq!(rig.text, "Kyoto", "and the edit goes on"); - } - - /// Esc gives back the text from before the edit and says the edit was cancelled. - #[test] - fn escape_gives_back_the_text_from_before_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - assert_eq!(rig.text, "Rome"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Lisbon"); - assert_eq!(rig.endings, vec![Ending::Escape]); - } - - /// Return ends the edit and keeps the text. - #[test] - fn return_ends_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.key(Key::Enter, Modifiers::NONE); - assert_eq!(rig.text, "Rome"); - assert_eq!(rig.endings, vec![Ending::Return]); - } - - /// Return with the menu open ends the edit as Return does, and closes the menu. - #[test] - fn return_with_the_menu_open_ends_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - rig.key(Key::Enter, Modifiers::NONE); - assert_eq!(rig.endings, vec![Ending::Return]); - assert_eq!(rig.text, "Rome"); - assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); - } - - /// Esc with the menu open closes only the menu, as in native menus: the edit goes on, and a - /// second Esc gives back the text. - #[test] - fn escape_with_the_menu_open_closes_only_the_menu() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - rig.key(Key::Escape, Modifiers::NONE); - assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); - assert!(rig.focused()); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); - } - - /// Esc after Tab took the focus into the open menu also closes only the menu. - #[test] - fn escape_from_inside_the_menu_closes_only_the_menu() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - rig.key(Key::Tab, Modifiers::NONE); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); - assert!(rig.focused()); - } - - /// Clicking elsewhere ends the edit and keeps the text. - #[test] - fn clicking_away_ends_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); - assert_eq!(rig.text, "Rome"); - assert_eq!(rig.endings, vec![Ending::Away]); - } - - /// Opening the menu and closing it without a choice (a click outside) leaves the field. - #[test] - fn dismissing_the_menu_leaves_the_field() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Primary); - rig.click(FIELD, PointerButton::Secondary); - rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); - assert!(!rig.focused()); - assert_eq!(rig.endings, vec![Ending::Away]); - } - - /// A field that selects on focus: clicking into it selects everything, so typing replaces it. - #[test] - fn a_field_that_selects_on_focus_is_retyped_whole() { - let mut rig = Rig::new("Lisbon"); - rig.select_on_focus = true; - rig.click(FIELD, PointerButton::Primary); - rig.type_text("Kyoto"); - assert_eq!(rig.text, "Kyoto"); - } - - /// Esc after using the menu still gives back the text from before the whole edit. - #[test] - fn escape_after_the_menu_gives_back_the_original_text() { - let mut rig = Rig::new("Lisbon"); - rig.view.clipboard = "Porto".into(); - rig.select_all_by_keys(); - rig.menu("paste"); - assert_eq!(rig.text, "Porto"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Lisbon"); - } - - /// A field that goes away in the middle of an edit (its dialog closed) starts afresh when it - /// comes back: Esc then gives back the text it came back with. - #[test] - fn an_edit_cut_short_is_forgotten() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.shown = false; - rig.settle(); - rig.text = "Porto".into(); - rig.shown = true; - rig.settle(); - rig.select_all_by_keys(); - rig.type_text("Kyoto"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Porto"); - } - - /// Switching to another app and back in the middle of an edit carries on with it: the field - /// is still being edited meanwhile, and Esc still gives back the text from before. - #[test] - fn switching_apps_carries_on_with_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.window_focused = false; - rig.settle(); - assert!(rig.editing, "still being edited while the app is in the background"); - assert_eq!(rig.endings, vec![]); - rig.window_focused = true; - rig.settle(); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); - } - - /// A field that goes away while its menu is open starts afresh too when it comes back. - #[test] - fn a_field_gone_with_its_menu_open_starts_afresh() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - // a click on the greyed-out Cut takes the focus into the menu - rig.click(&format!("{FIELD}:cut"), PointerButton::Primary); - rig.shown = false; - rig.settle(); - rig.text = "Porto".into(); - rig.shown = true; - rig.settle(); - assert!(!rig.editing, "no edit under way"); - assert_eq!(rig.endings, vec![], "and none ended: it was cut short"); - rig.select_all_by_keys(); - rig.type_text("Kyoto"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Porto"); - } - - /// A field that selects on focus still lets a drag into it select part of the text, as a - /// browser's address bar does: only a plain click selects everything. - #[test] - fn a_drag_into_a_field_that_selects_on_focus_selects_what_it_covers() { - let mut rig = Rig::new("Lisbon Portugal Europe Atlantic Ocean"); - rig.select_on_focus = true; - let r = rig.rect(FIELD).expect("the field"); - let (from, to) = (r.center(), egui::pos2(r.right() + 40.0, r.center().y)); - let press = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - rig.frame(vec![Event::PointerMoved(from), press(from, true)]); - for i in 1..=5 { - rig.frame(vec![Event::PointerMoved(from + (to - from) * (i as f32 / 5.0))]); - } - rig.frame(vec![press(to, false)]); - rig.settle(); - rig.type_text("X"); - assert!(rig.text.starts_with("Lisbon") && rig.text.ends_with('X') && rig.text != "X", "{:?}", rig.text); - } -} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6c2bac6ac..e55008efd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -386,7 +386,7 @@ pub const WHEEL_STRIP_SHARE: f32 = 1.0 / 3.0; pub const WHEEL_ZOOM_PER_POINT: f32 = 40.0 / 200.0 / WHEEL_LINE_POINTS; fn theme_id() -> egui::Id { - egui::Id::new("lightcraft-theme") + egui::Id::unique("lightcraft-theme") } /// The theme `ctx` is styled with (none before the first [`apply_kind`]). diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs deleted file mode 100644 index 40c8975cd..000000000 --- a/crates/ui-egui/src/titlebar.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! The integrated title bar's window gestures (macOS: the native title bar is hidden and the top -//! bar takes its place, so dragging and double-click-to-zoom have to be done here). -//! -//! Pure policy, no egui widgets: [`command_for`] maps a gesture on the bar's empty space and the -//! window's current state to the viewport command the host should run. - -use egui::ViewportCommand; - -/// What the user did on the bar's empty space. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Gesture { - /// A primary-button drag began: the window follows the pointer. - DragStarted, - /// A primary-button double-click: zoom the window, or restore it when already zoomed. - DoubleClicked, -} - -/// The window as the host reports it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct WindowState { - /// Zoomed (macOS) / maximized (elsewhere). - pub maximized: bool, - /// In the OS's full-screen mode. - pub fullscreen: bool, -} - -/// The viewport command for `gesture`, if any. Full screen has no title bar to drag or zoom. -pub fn command_for(gesture: Gesture, window: WindowState) -> Option { - if window.fullscreen { - return None; - } - match gesture { - Gesture::DragStarted => Some(ViewportCommand::StartDrag), - Gesture::DoubleClicked => Some(ViewportCommand::Maximized(!window.maximized)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const NORMAL: WindowState = WindowState { maximized: false, fullscreen: false }; - const ZOOMED: WindowState = WindowState { maximized: true, fullscreen: false }; - const FULL: WindowState = WindowState { maximized: false, fullscreen: true }; - - // Scenario: dragging the title bar moves the window - #[test] - fn given_a_normal_window_when_dragging_then_the_window_follows_the_pointer() { - assert_eq!(command_for(Gesture::DragStarted, NORMAL), Some(ViewportCommand::StartDrag)); - } - - // Scenario: a zoomed window can still be dragged (macOS lets you pull it free) - #[test] - fn given_a_zoomed_window_when_dragging_then_the_window_follows_the_pointer() { - assert_eq!(command_for(Gesture::DragStarted, ZOOMED), Some(ViewportCommand::StartDrag)); - } - - // Scenario: double-click zooms - #[test] - fn given_a_normal_window_when_double_clicking_then_it_zooms() { - assert_eq!(command_for(Gesture::DoubleClicked, NORMAL), Some(ViewportCommand::Maximized(true))); - } - - // Scenario: double-click again restores - #[test] - fn given_a_zoomed_window_when_double_clicking_then_it_is_restored() { - assert_eq!(command_for(Gesture::DoubleClicked, ZOOMED), Some(ViewportCommand::Maximized(false))); - } - - // Scenario: full screen ignores both - #[test] - fn given_a_full_screen_window_then_neither_gesture_does_anything() { - assert_eq!(command_for(Gesture::DragStarted, FULL), None); - assert_eq!(command_for(Gesture::DoubleClicked, FULL), None); - } -} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 01d5b65f8..558edfe8d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -9,14 +9,21 @@ use crate::icons::{Icon, paint}; use crate::theme::Tokens; /// Standard dropdown chevrons, including settings, import/export and rule editors. -pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox { - egui::ComboBox::from_id_salt(id) +pub fn combo_box(id: impl egui::AsIdSalt) -> craft_ui::ComboBox<'static> { + craft_ui::ComboBox::from_id_salt(id) .icon(|ui, rect, visuals, _| { paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, visuals.fg_stroke.color); }) .popup_style(list_style.into()) } +/// Name a dropdown for screen readers. egui 0.37's `ComboBox::from_id_salt` publishes only the +/// selected text as the node's value, so an otherwise unlabelled combo has no accessible name and +/// the strict kittest check rejects it. Call this on the response returned by `show_ui`. +pub fn name_combo(response: &egui::Response, label: &str) { + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, response.enabled(), label)); +} + /// A dropdown's list and its submenus: items without the resting border of a button, as egui's /// own menus draw them. An unhovered item gives up its border's width of padding, so with one it /// grew 2 points when hovered, and the longest name of a list sized to it wrapped (#2354). @@ -26,7 +33,9 @@ fn list_style(style: &mut egui::Style) { /// A native button with a vector arrow beside its text; egui reserves the icon's space. pub fn arrow_button<'a>(ui: &mut Ui, text: impl egui::IntoAtoms<'a>, icon: Icon, leading: bool) -> Response { - let id = ui.next_auto_id().with("arrow"); + // 0.37 atoms take a local `IdSalt` (not a root `Id`); the button reports the custom atom's + // rect under the same salt. + let id = egui::IdSalt::new(("lc-arrow", ui.next_auto_id())); let mut atoms = text.into_atoms(); let arrow = egui::Atom::custom(id, vec2(16.0, 16.0)); if leading { @@ -70,29 +79,111 @@ pub fn context_menu_close(response: &Response, default: egui::PopupCloseBehavior // ------------------------------------------------------------------ automation registry -#[derive(Clone, Default)] -pub struct Registry(pub Vec<(String, Rect)>); +/// The real egui widget that owns a registered automation rectangle. +/// +/// Automation ids are names for scripts; egui hit-tests widget [`egui::Id`]s in a [`egui::LayerId`]. +/// [`register`] records a small non-interactive probe with the registered rect so the control +/// channel can ask egui itself whether that rectangle owns the pointer, rather than guessing from +/// unrelated interactive rectangles. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WidgetHit { + pub id: egui::Id, + pub layer_id: egui::LayerId, + /// The widget's clipped interaction rectangle in its layer's local coordinates, recorded in + /// the same pass as the registration. Delivery checks this instead of calling + /// `Context::read_response` before the next pass: at that point `read_response` can still see + /// the pass before the registry that was just published. + pub interact_rect: Rect, +} -/// Record a widget's automation id and screen rect for this frame. -pub fn register(ctx: &egui::Context, id: impl Into, rect: Rect) { +#[derive(Clone, Default)] +pub struct Registry { + pub rects: Vec<(String, Rect)>, + pub hits: Vec<(String, WidgetHit)>, + /// How many times each automation id was registered in this pass (for stable duplicate probe + /// ids). A map keeps registration linear; scanning `rects` would make a pass O(N²). + occurrences: std::collections::HashMap, +} + +/// Record a widget's automation id and screen rect for this frame, and register a hover probe at +/// the same rectangle in the current UI layer. +/// +/// The probe senses hover only (no click or drag), so it never takes a click from the widget it +/// describes. It records the current UI layer and its clipped `Response::interact_rect` in the +/// same pass, and the control channel asks egui for the top-most interactable layer at the point. +/// A probe below a non-modal foreground/dim overlay is therefore refused without ever pressing the +/// overlay. A point below the top modal dialog is instead left to egui's modal routing: the dialog +/// consumes it, or a click on the backdrop closes the dialog, and no background widget is pressed. +pub fn register(ui: &egui::Ui, id: impl Into, rect: Rect) { + let ctx = ui.ctx(); + let registry = egui::Id::unique("lc-registry"); let id = id.into(); - ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("lc-registry")).0.push((id, rect))); + // Stable across frames even when unrelated widgets come and go: the probe id names the + // automation id (and a duplicate occurrence of the same id in this pass), not its position. + let duplicate = ctx.data_mut(|d| { + let registry = d.get_temp_mut_or_default::(registry); + let occurrence = registry.occurrences.entry(id.clone()).or_insert(0); + let duplicate = *occurrence; + *occurrence = occurrence.saturating_add(1); + duplicate + }); + let hit = rect.is_finite().then(|| { + let id = egui::Id::unique(("lc-automation-hit", id.as_str(), duplicate)); + let response = ui.interact(rect, id, egui::Sense::hover()); + WidgetHit { id, layer_id: response.layer_id, interact_rect: response.interact_rect } + }); + ctx.data_mut(|d| { + let registry = d.get_temp_mut_or_default::(registry); + registry.rects.push((id.clone(), rect)); + if let Some(hit) = hit { + registry.hits.push((id, hit)); + } + }); +} + +/// Record a widget whose real egui [`egui::Response`] is available. More precise than +/// [`register`]'s hover probe: the response carries the widget's own id, layer and clipped +/// geometry, and remains a valid automation target when disabled (`ui.clickWidget` on a disabled +/// control is a successful no-op, which is the documented automation semantics). +pub fn register_response(id: impl Into, rect: Rect, response: &egui::Response) { + let id = id.into(); + let ctx = response.ctx.clone(); + ctx.data_mut(|d| { + let registry = d.get_temp_mut_or_default::(egui::Id::unique("lc-registry")); + registry.rects.push((id.clone(), rect)); + registry.hits.push((id, WidgetHit { id: response.id, layer_id: response.layer_id, interact_rect: response.interact_rect })); + }); +} + +/// Record painted geometry that has no interactive egui widget of its own (a preview image, a +/// drop card). `ui.clickWidget` will not press it: there is no widget hit to own the press. +pub fn register_geometry(ctx: &egui::Context, id: impl Into, rect: Rect) { + let id = id.into(); + ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("lc-registry")).rects.push((id, rect))); } /// Esc was handled this frame by something nested (an open popup closed on it): containers such /// as dialogs shouldn't close too. Keyed to the frame, so it lapses by itself on the next one. pub fn take_escape(ctx: &egui::Context) { let frame = ctx.cumulative_frame_nr(); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("lc-escape-taken"), frame)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("lc-escape-taken"), frame)); } /// Whether [`take_escape`] was called this frame. pub fn escape_taken(ctx: &egui::Context) -> bool { - ctx.data(|d| d.get_temp::(egui::Id::new("lc-escape-taken"))) == Some(ctx.cumulative_frame_nr()) + ctx.data(|d| d.get_temp::(egui::Id::unique("lc-escape-taken"))) == Some(ctx.cumulative_frame_nr()) } +/// Take this pass's registered rectangles and their egui hit owners. The app publishes both only +/// for a completed, nondiscarded pass. +pub fn take_registries(ctx: &egui::Context) -> (Vec<(String, Rect)>, Vec<(String, WidgetHit)>) { + let registry = ctx.data_mut(|d| std::mem::take(d.get_temp_mut_or_default::(egui::Id::unique("lc-registry")))); + (registry.rects, registry.hits) +} + +/// Take only the registered rectangles (for unit tests that inspect layout). pub fn take_registry(ctx: &egui::Context) -> Vec<(String, Rect)> { - ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::(egui::Id::new("lc-registry")).0)) + take_registries(ctx).0 } // ------------------------------------------------------------------ preview-only raws @@ -143,8 +234,8 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) -> bool { }) .response; let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason)); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG"))); - register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG"))); + register(ui, format!("notice:previewOnly:{key}"), r.rect); reload } @@ -262,7 +353,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ 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.id().with(spec.id); + 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() }); // 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`). @@ -324,14 +415,14 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let reset_hint = crate::i18n::tr_format!("{name} — double-click to reset it", name = label_text); let label_resp = if enabled { label_resp.on_hover_text(reset_hint.as_str()) } else { label_resp }; crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone())); - register(ui.ctx(), format!("sliderLabel:{}", spec.id), label_rect); - register(ui.ctx(), format!("slider:{}", spec.id), track_rect); + register(ui, format!("sliderLabel:{}", spec.id), label_rect); + 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.ctx(), format!("sliderValue:{}", spec.id), value_rect); + 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"); @@ -618,14 +709,24 @@ pub fn nudged(spec: &ControlSpec, value: f64, steps: f64) -> f64 { (((value + unit * steps) / step).round() * step).clamp(spec.min, spec.max) } +/// Announce a disclosure row with AccessKit's real `Expanded` state. +/// +/// egui 0.37's `WidgetInfo::selected` maps to AccessKit `Toggled`; a custom disclosure row must +/// publish `Expanded` instead (the built-in `CollapsingHeader` does this through +/// `accesskit_node_builder`). Use one helper so section headers, flyouts and fold titles agree. +pub(crate) fn announce_disclosure(resp: &egui::Response, enabled: bool, expanded: bool, label: impl ToString) { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::DisclosureTriangle, enabled, label.to_string())); + resp.ctx.accesskit_node_builder(resp.id, |node| node.set_expanded(expanded)); +} + /// Collapsible section header ("› Light"). Returns the response (click toggles). pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: Option) -> (Response, Option) { let title = crate::i18n::tr(title); let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); - register(ui.ctx(), format!("section:{id}"), r); + announce_disclosure(&resp, true, open, title); + register(ui, format!("section:{id}"), r); let p = ui.painter(); if ui.rect_contains_pointer(r) { p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06)); @@ -641,9 +742,9 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O && (ui.rect_contains_pointer(r) || !on) { let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0)); - let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); + let er = ui.interact(eye, ui.scope_id().with(("eye", id)), Sense::click()); crate::widgets::announce_selected(&er, crate::i18n::tr("Show / hide"), on); - register(ui.ctx(), format!("sectionEye:{id}"), eye); + register(ui, format!("sectionEye:{id}"), eye); paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled }); if !on { ui.painter().line_segment([eye.left_bottom(), eye.right_top()], Stroke::new(1.5, t.text_disabled)); @@ -670,9 +771,9 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> let w = ui.available_width(); let (outer, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let r = Rect::from_min_max(pos2(outer.left() + 16.0, outer.top() + 4.0), pos2(outer.right() - 16.0, outer.bottom() - 4.0)); - let resp = ui.interact(r, ui.id().with(("flyout", id)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); - register(ui.ctx(), format!("flyout:{id}"), r); + let resp = ui.interact(r, ui.scope_id().with(("flyout", id)), Sense::click()); + announce_disclosure(&resp, true, open, title); + register(ui, format!("flyout:{id}"), r); let p = ui.painter(); p.rect_filled(r, 4.0, if resp.hovered() { t.hover.gamma_multiply(0.8) } else { t.inset }); let tile = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(r.height() - 8.0, r.height() - 8.0)); @@ -696,8 +797,8 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text); let size = vec2((galley.size().x + 18.0).max(37.0), 24.0); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, label)); - register(ui.ctx(), format!("button:{id}"), r); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); + register(ui, format!("button:{id}"), r); let fill = if active { t.pressed } else if resp.hovered() { @@ -728,7 +829,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option) { let name = name.into(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), name.clone())); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), name.clone())); } /// The same for one that is on or off (a toggle, a selected row or swatch). pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into, selected: bool) { let name = name.into(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, resp.enabled(), selected, name.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, resp.enabled(), selected, name.clone())); } /// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn @@ -1010,21 +1111,27 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S "…".to_string() } +pub mod album_picker; +pub mod button; +pub mod date_picker; #[cfg(test)] #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; +pub mod progress; +pub mod rating; +pub mod text_field; #[cfg(test)] #[path = "widgets/tests_slider_reset.rs"] mod slider_reset_tests; fn focused_slider_key() -> egui::Id { - egui::Id::new("lightcraft-focused-slider") + egui::Id::unique("lightcraft-focused-slider") } /// The slider a click or drag armed for vertical wheel edits, if any. fn wheel_slider_key() -> egui::Id { - egui::Id::new("lightcraft-wheel-slider") + egui::Id::unique("lightcraft-wheel-slider") } /// Whether the widget with keyboard focus is a develop slider selected by a click, drag or Tab. diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/widgets/album_picker.rs similarity index 60% rename from crates/ui-egui/src/album_picker.rs rename to crates/ui-egui/src/widgets/album_picker.rs index 835dbcce8..a88e5f1aa 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/widgets/album_picker.rs @@ -166,6 +166,9 @@ struct State { focus: bool, } +/// The popup's maximum height: the search field and the list. +const MENU_HEIGHT: f32 = 320.0; + /// A button that edits an album id; see the module docs. pub struct AlbumPicker<'a> { id: String, @@ -211,158 +214,179 @@ impl<'a> AlbumPicker<'a> { ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text.as_str()).truncate().right_text(" ")).on_hover_text(text); let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); - register(ui.ctx(), format!("albumPicker:{id}"), button.rect); - let key = egui::Id::new(("albumPicker", id.as_str())); + register(ui, format!("albumPicker:{id}"), button.rect); + let key = egui::Id::unique(("albumPicker", id.as_str())); if button.clicked() { // open on the chosen album's folders, with the search empty and ready for typing let open = chosen.map(|c| ancestors(entries, c)).unwrap_or_default(); ui.data_mut(|d| d.insert_temp(key, State { open, focus: true, ..Default::default() })); } - let mut state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default(); - let mut picked: Option = None; - egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - // A wide popup remembered before a window resize must still fit the current viewport. - let margin = egui::Frame::popup(ui.style()).total_margin().sum().x; - let max_width = (ui.ctx().content_rect().width() - margin).max(1.0); - ui.set_min_width(width.max(260.0).min(max_width)); - ui.set_max_width((width.max(260.0) + 120.0).min(max_width)); - if choices(ui) { - ui.close(); - return; - } - let search_box = - ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY)); - register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect); - if state.focus { - search_box.request_focus(); - state.focus = false; - } - if search_box.changed() { - state.highlight = 0; - } - let t = crate::theme::Tokens::get(ui.ctx()); - // `scroll`: bring the row into view (the arrow keys just moved to it); never every frame, - // which would fight the mouse wheel - let row = |ui: &mut Ui, e: &AlbumEntry, label: &str, indent: f32, highlighted: bool, scroll: bool, picked: &mut Option| { - ui.horizontal(|ui| { - ui.add_space(indent); - let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); - let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album }; - crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon }); - let resp = ui - .add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label).truncate()) - .on_hover_text(label); - register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect); - let resp = match &e.blocked { - Some(why) => resp.on_disabled_hover_text(why.as_str()), - None => resp, - }; - if resp.clicked() { - *picked = Some(e.id); + let state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default(); + let state = std::cell::RefCell::new(state); + let picked = craft_ui::combo::Menu::new(ui, &button) + .height(MENU_HEIGHT) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui_with_header( + |ui| { + // A wide popup remembered before a window resize must still fit the current viewport. + let margin = egui::Frame::popup(ui.style()).total_margin().sum().x; + let max_width = (ui.ctx().content_rect().width() - margin).max(1.0); + ui.set_min_width(width.max(260.0).min(max_width)); + ui.set_max_width((width.max(260.0) + 120.0).min(max_width)); + // Esc closes the popup and stops there: the dialog around it keeps its edits + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + crate::widgets::take_escape(ui.ctx()); + ui.close(); } - if scroll { - resp.scroll_to_me(None); + if choices(ui) { + ui.close(); + return true; } - }); - }; - egui::ScrollArea::vertical().max_height(320.0).auto_shrink([false, true]).show(ui, |ui| { - if state.query.trim().is_empty() { - for i in visible_tree(entries, &state.open) { - let e = &entries[i]; - let indent = e.depth as f32 * 16.0; - if e.folder { - // a folder row folds and unfolds, like the sidebar's: triangle, icon, name - let open = state.open.contains(&e.id); - let height = ui.spacing().interact_size.y; - let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), height), egui::Sense::click()); - if r.hovered() { - ui.painter().rect_filled(rect, 3.0, t.hover); + let mut state = state.borrow_mut(); + let search_box = + ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY)); + register(ui, format!("albumPickerSearch:{id}"), search_box.rect); + if state.focus { + search_box.request_focus(); + state.focus = false; + } + if search_box.changed() { + state.highlight = 0; + } + false + }, + |ui, closed| { + let mut picked: Option = None; + if closed { + return picked; + } + let mut state = state.borrow_mut(); + let t = crate::theme::Tokens::get(ui.ctx()); + // `scroll`: bring the row into view (the arrow keys just moved to it); never every frame, + // which would fight the mouse wheel + let row = |ui: &mut Ui, e: &AlbumEntry, label: &str, indent: f32, highlighted: bool, scroll: bool, picked: &mut Option| { + ui.horizontal(|ui| { + ui.add_space(indent); + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); + let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album }; + crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon }); + let resp = ui + .add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label).truncate()) + .on_hover_text(label); + register(ui, format!("albumPickerItem:{}:{id}", e.id), resp.rect); + let resp = match &e.blocked { + Some(why) => resp.on_disabled_hover_text(why.as_str()), + None => resp, + }; + if resp.clicked() { + *picked = Some(e.id); } - let x = rect.left() + indent; - disclosure(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); - let icon = egui::Rect::from_center_size(egui::pos2(x + 23.0, rect.center().y), vec2(14.0, 14.0)); - crate::icons::paint(ui.painter(), icon, Icon::Folder, t.icon); - ui.painter().text( - egui::pos2(x + 36.0, rect.center().y), - egui::Align2::LEFT_CENTER, - &e.name, - egui::TextStyle::Button.resolve(ui.style()), - t.text_label, - ); - register(ui.ctx(), format!("albumPickerFolder:{}:{id}", e.id), r.rect); - // what assistive tools announce: the folder, and whether it is open - let name = e.name.clone(); - r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, &name)); - if r.clicked() { - // the click took the keyboard from the search box: give it back - state.focus = true; - if open { - state.open.retain(|o| *o != e.id); + if scroll { + resp.scroll_to_me(None); + } + }); + }; + { + if state.query.trim().is_empty() { + for i in visible_tree(entries, &state.open) { + let e = &entries[i]; + let indent = e.depth as f32 * 16.0; + if e.folder { + // a folder row folds and unfolds, like the sidebar's: triangle, icon, name + let open = state.open.contains(&e.id); + let height = ui.spacing().interact_size.y; + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), height), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let x = rect.left() + indent; + disclosure( + ui.painter(), + egui::pos2(x + 7.0, rect.center().y), + open, + if r.hovered() { t.text } else { t.text_dim }, + ); + let icon = egui::Rect::from_center_size(egui::pos2(x + 23.0, rect.center().y), vec2(14.0, 14.0)); + crate::icons::paint(ui.painter(), icon, Icon::Folder, t.icon); + ui.painter().text( + egui::pos2(x + 36.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &e.name, + egui::TextStyle::Button.resolve(ui.style()), + t.text_label, + ); + register(ui, format!("albumPickerFolder:{}:{id}", e.id), r.rect); + // what assistive tools announce: the folder, and whether it is open + let name = e.name.clone(); + crate::widgets::announce_disclosure(&r, true, open, &name); + if r.clicked() { + // the click took the keyboard from the search box: give it back + state.focus = true; + if open { + state.open.retain(|o| *o != e.id); + } else { + state.open.push(e.id); + } + } } else { - state.open.push(e.id); + // past the triangle column, so names line up with folder names + row(ui, e, &e.name, indent + 14.0, false, false, &mut picked); } } + if entries.iter().all(|e| e.folder) { + ui.label(RichText::new(crate::i18n::tr("No albums yet")).weak()); + } } else { - // past the triangle column, so names line up with folder names - row(ui, e, &e.name, indent + 14.0, false, false, &mut picked); + let hits = search(entries, &state.query); + // ↑ / ↓ move through the results that can be picked, Enter picks one + let pickable: Vec = hits.iter().copied().filter(|&i| entries[i].blocked.is_none()).collect(); + let (down, up, enter) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), + ) + }); + if down { + state.highlight = (state.highlight + 1).min(pickable.len().saturating_sub(1)); + } + if up { + state.highlight = state.highlight.saturating_sub(1); + } + let highlighted = pickable.get(state.highlight).copied(); + if enter { + match highlighted { + Some(i) => picked = Some(entries[i].id), + // Enter took the keyboard from the search box and picked nothing: give it back + None => state.focus = true, + } + } + let by_id = index(entries); + for i in &hits { + let e = &entries[*i]; + row( + ui, + e, + &path_in(entries, &by_id, e.id), + 0.0, + highlighted == Some(*i), + (up || down) && highlighted == Some(*i), + &mut picked, + ); + } + if hits.is_empty() { + ui.label(RichText::new(crate::i18n::tr("No albums match")).weak()); + } } } - if entries.iter().all(|e| e.folder) { - ui.label(RichText::new(crate::i18n::tr("No albums yet")).weak()); + if picked.is_some() { + ui.close(); } - } else { - let hits = search(entries, &state.query); - // ↑ / ↓ move through the results that can be picked, Enter picks one - let pickable: Vec = hits.iter().copied().filter(|&i| entries[i].blocked.is_none()).collect(); - let (down, up, enter) = ui.input_mut(|i| { - ( - i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), - i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), - i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), - ) - }); - if down { - state.highlight = (state.highlight + 1).min(pickable.len().saturating_sub(1)); - } - if up { - state.highlight = state.highlight.saturating_sub(1); - } - let highlighted = pickable.get(state.highlight).copied(); - if enter { - match highlighted { - Some(i) => picked = Some(entries[i].id), - // Enter took the keyboard from the search box and picked nothing: give it back - None => state.focus = true, - } - } - let by_id = index(entries); - for i in &hits { - let e = &entries[*i]; - row( - ui, - e, - &path_in(entries, &by_id, e.id), - 0.0, - highlighted == Some(*i), - (up || down) && highlighted == Some(*i), - &mut picked, - ); - } - if hits.is_empty() { - ui.label(RichText::new(crate::i18n::tr("No albums match")).weak()); - } - } - }); - // Esc closes the popup and stops there: the dialog around it keeps its edits - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - crate::widgets::take_escape(ui.ctx()); - ui.close(); - } - if picked.is_some() { - ui.close(); - } - }); - ui.data_mut(|d| d.insert_temp(key, state)); + picked + }, + ) + .flatten(); + ui.data_mut(|d| d.insert_temp(key, state.into_inner())); if let Some(p) = picked { *value = Some(p); button.mark_changed(); diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/widgets/button.rs similarity index 98% rename from crates/ui-egui/src/button.rs rename to crates/ui-egui/src/widgets/button.rs index 4e23f1c16..d3fa98821 100644 --- a/crates/ui-egui/src/button.rs +++ b/crates/ui-egui/src/widgets/button.rs @@ -332,7 +332,7 @@ impl<'a> Button<'a> { // stable across frames and moves: its widget id (with its salt) when it has one, else its // place in the ui (moved on, so the next button in the same ui gets another) let id = match &self.widget { - Some(w) => ui.id().with(("lc-button", w.as_str(), self.id_salt)), + Some(w) => ui.scope_id().with(("lc-button", w.as_str(), self.id_salt)), None => { let id = ui.auto_id_with(("lc-button", self.text.or(self.name), self.id_salt)); ui.skip_ahead_auto_ids(1); @@ -354,12 +354,12 @@ impl<'a> Button<'a> { }, ); let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone())); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, words.clone())); if self.busy { ui.ctx().accesskit_node_builder(resp.id, |node| node.set_busy()); } if let Some(w) = &self.widget { - crate::widgets::register(ui.ctx(), w.clone(), rect); + crate::widgets::register(ui, w.clone(), rect); } let state = if !enabled { State::Disabled diff --git a/crates/ui-egui/src/date_picker.rs b/crates/ui-egui/src/widgets/date_picker.rs similarity index 96% rename from crates/ui-egui/src/date_picker.rs rename to crates/ui-egui/src/widgets/date_picker.rs index c84df98c4..eda461eb7 100644 --- a/crates/ui-egui/src/date_picker.rs +++ b/crates/ui-egui/src/widgets/date_picker.rs @@ -238,8 +238,8 @@ impl<'a> DatePicker<'a> { pub fn show(self, ui: &mut Ui) -> Response { let DatePicker { id, value, precisions, min, max, today } = self; let mut button = crate::widgets::icon_button(ui, &format!("datePicker:{id}"), Icon::Calendar, vec2(24.0, 22.0), false, true, "Pick a date"); - register(ui.ctx(), format!("datePicker:{id}"), button.rect); - let state = egui::Id::new(("datePicker", id.as_str())); + register(ui, format!("datePicker:{id}"), button.rect); + let state = egui::Id::unique(("datePicker", id.as_str())); if button.clicked() { // open on the value (or today), at its precision when it is one of those offered let at = value.or(today).unwrap_or(PickedDate::year(2026)); @@ -257,7 +257,7 @@ impl<'a> DatePicker<'a> { ui.horizontal(|ui| { for p in precisions { let r = ui.selectable_label(view.precision == *p, crate::i18n::tr(p.label())); - register(ui.ctx(), format!("datePickerPrecision:{}:{id}", p.id()), r.rect); + register(ui, format!("datePickerPrecision:{}:{id}", p.id()), r.rect); if r.clicked() { view.precision = *p; } @@ -267,8 +267,8 @@ impl<'a> DatePicker<'a> { } ui.horizontal(|ui| { let prev = crate::widgets::arrow_button(ui, "", Icon::ChevronLeft, true); - prev.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Previous page"))); - register(ui.ctx(), format!("datePickerPrev:{id}"), prev.rect); + prev.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Previous page"))); + register(ui, format!("datePickerPrev:{id}"), prev.rect); let title = match view.precision { Precision::Day => crate::i18n::date_group_label(&format!("{:04}-{:02}", view.year, view.month), false), Precision::Month => format!("{:04}", view.year), @@ -280,8 +280,8 @@ impl<'a> DatePicker<'a> { ui.label(RichText::new(title).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let next = crate::widgets::arrow_button(ui, "", Icon::ChevronRight, false); - next.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Next page"))); - register(ui.ctx(), format!("datePickerNext:{id}"), next.rect); + next.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Next page"))); + register(ui, format!("datePickerNext:{id}"), next.rect); if next.clicked() { view = step(view, true); } @@ -294,7 +294,7 @@ impl<'a> DatePicker<'a> { let allowed = in_bounds(&date, min.as_ref(), max.as_ref()); let text = if holds_today(&date, today) { RichText::new(text).strong().underline() } else { RichText::new(text) }; let r = ui.add_enabled(allowed, egui::Button::selectable(*value == Some(date), text).min_size(vec2(26.0, 0.0))); - register(ui.ctx(), format!("datePickerCell:{}:{id}", date.iso()), r.rect); + register(ui, format!("datePickerCell:{}:{id}", date.iso()), r.rect); if r.clicked() { *picked = Some(date); } diff --git a/crates/ui-egui/src/progress.rs b/crates/ui-egui/src/widgets/progress.rs similarity index 95% rename from crates/ui-egui/src/progress.rs rename to crates/ui-egui/src/widgets/progress.rs index 0f2dce012..ab7ba9603 100644 --- a/crates/ui-egui/src/progress.rs +++ b/crates/ui-egui/src/widgets/progress.rs @@ -109,14 +109,14 @@ impl Progress { let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); resp.widget_info(|| { let mut info = match label { - Some(l) => egui::WidgetInfo::labeled(egui::WidgetType::ProgressIndicator, ui.is_enabled(), l), - None => egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator), + Some(l) => egui::WidgetInfo::labeled(egui::Role::ProgressIndicator, ui.is_enabled(), l), + None => egui::WidgetInfo::new(egui::Role::ProgressIndicator), }; info.value = self.fraction.map(|f| f64::from(percent(f))); info }); if let Some(w) = &self.widget { - crate::widgets::register(ui.ctx(), w.clone(), rect); + crate::widgets::register(ui, w.clone(), rect); } if !ui.is_rect_visible(rect) { return resp; @@ -182,7 +182,7 @@ pub fn paint_spinner(p: &egui::Painter, rect: Rect, color: Color32, time: f64) { pub fn spinner(ui: &mut Ui, size: f32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); - resp.widget_info(|| egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator)); + resp.widget_info(|| egui::WidgetInfo::new(egui::Role::ProgressIndicator)); if ui.is_rect_visible(rect) { paint_spinner(ui.painter(), rect, t.text_dim, ui.input(|i| i.time)); ui.ctx().request_repaint_after(FRAME); diff --git a/crates/ui-egui/src/rating.rs b/crates/ui-egui/src/widgets/rating.rs similarity index 98% rename from crates/ui-egui/src/rating.rs rename to crates/ui-egui/src/widgets/rating.rs index 16cd611d2..8b1183eb6 100644 --- a/crates/ui-egui/src/rating.rs +++ b/crates/ui-egui/src/widgets/rating.rs @@ -122,7 +122,7 @@ impl Rating { } fn star_id(&self, ui: &Ui, i: u8) -> egui::Id { - ui.id().with(("rating-star", &self.id, i)) + ui.scope_id().with(("rating-star", &self.id, i)) } /// Draw the row. Returns the new rating when a click or an arrow key changed it. @@ -142,7 +142,7 @@ impl Rating { let sense = if i == stop || ui.memory(|m| m.has_focus(id)) { Sense::click() } else { Sense::CLICK }; let resp = ui.interact(r, id, sense); announce_selected(&resp, format!("{} ★", i + 1), i < self.rating); - register(ui.ctx(), self.widget_id(i + 1), r); + register(ui, self.widget_id(i + 1), r); if resp.hovered() { hover_n = Some(i + 1); } @@ -201,7 +201,7 @@ pub fn favorite_button(ui: &mut Ui, widget: String, favorite: bool, name: &str) announce_selected(&resp, name, favorite); // (rows scrolled out of view can't be clicked) if ui.is_rect_visible(r) { - register(ui.ctx(), widget, r); + register(ui, widget, r); } if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index eb2e3c9b8..0bd49abae 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -206,6 +206,7 @@ fn leaving_a_slider_disarms_wheel_edits_until_it_is_selected_again() { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, modifiers: Modifiers::NONE, }; h.frame(vec![Event::PointerMoved(inside)]); @@ -232,7 +233,13 @@ fn leaving_a_slider_disarms_wheel_edits_until_it_is_selected_again() { let before_sideways = h.value; h.frame(vec![ Event::PointerMoved(inside), - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(120.0, 0.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(120.0, 0.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers: Modifiers::NONE, + }, ]); for _ in 0..40 { h.frame(vec![]); @@ -270,7 +277,13 @@ fn leaving_the_row_during_a_drag_keeps_dragging_but_release_disarms_the_wheel() let released = h.value; h.frame(vec![ Event::PointerMoved(inside), - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -120.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers: Modifiers::NONE, + }, ]); for _ in 0..40 { h.frame(vec![]); @@ -294,6 +307,7 @@ fn a_drag_that_leaves_and_ends_back_in_the_row_disarms_the_wheel() { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, modifiers: Modifiers::NONE, }]); for _ in 0..40 { @@ -308,6 +322,7 @@ fn leaving_and_returning_within_one_frame_still_disarms_the_wheel() { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, modifiers: Modifiers::NONE, }; // a slow frame merges the moves out of the row and back (or out of the window and back) diff --git a/crates/ui-egui/src/widgets/text_field.rs b/crates/ui-egui/src/widgets/text_field.rs new file mode 100644 index 000000000..cb150080b --- /dev/null +++ b/crates/ui-egui/src/widgets/text_field.rs @@ -0,0 +1,410 @@ +//! A text field that behaves the same everywhere in the app: right-click for Cut, Copy, Paste and +//! Select All; Esc gives back the text from before the edit; Return or leaving the field ends the +//! edit; optionally everything is selected when it takes focus, for fields that are retyped whole. +//! +//! It knows nothing of the app's documents or commands, only the UI's plumbing (the automation +//! registry, translations, shortcut labels). The caller owns the text and learns how an edit +//! ended from [`TextFieldResponse::ending`]; what an ending means (save, apply, close) is the +//! caller's. Each field's widget id is unique on screen: its state is keyed by it. +//! +//! New text boxes use it rather than `egui::TextEdit`. +//! +//! The keyboard's ⌘X / ⌘C / ⌘V / ⌘A and undo are egui's own, and the app's shortcuts step aside +//! while a field has focus (`shortcuts::handle`, the native menu's `yields_to_text`). + +use egui::{Response, Ui}; + +/// How an edit ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ending { + /// Return in a one-line field: the text is meant. + Return, + /// The focus went elsewhere (a click outside, Tab): the text is kept. + Away, + /// Esc: the text is back to what it was before the edit. + Escape, +} + +pub struct TextFieldResponse { + pub response: Response, + /// Set on the frame an edit ends. + pub ending: Option, + /// An edit is under way: the field has the focus, or its menu does. + pub editing: bool, +} + +impl TextFieldResponse { + /// The edit ended with the text kept (Return or leaving the field). + pub fn committed(&self) -> bool { + matches!(self.ending, Some(Ending::Return | Ending::Away)) + } + + /// Esc ended the edit and the text is back as it was. + pub fn cancelled(&self) -> bool { + self.ending == Some(Ending::Escape) + } +} + +/// A one-line or multi-line text field, registered under a widget id for the control channel. +pub struct TextField<'a> { + widget: &'a str, + text: &'a mut String, + multiline: bool, + hint: Option, + label: Option, + width: Option, + select_on_focus: bool, + begin_edit: bool, + frame: bool, + font: Option, + color: Option, + align: Option, + margin: Option, + rows: Option, +} + +/// The egui id of the field registered as `widget`, to give it the focus (⌘F to search). +pub fn id(widget: &str) -> egui::Id { + egui::Id::unique("lc-text-field").with(widget) +} + +/// Whether the field had an unfinished edit on the immediately preceding egui pass. +pub(crate) fn was_editing(ctx: &egui::Context, widget: &str) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(id(widget).with("memo")).is_some_and(|memo| memo.before.is_some() && memo.pass.checked_add(1) == Some(pass))) +} + +impl<'a> TextField<'a> { + /// A one-line field (Return ends the edit) registered as `widget`. + pub fn singleline(widget: &'a str, text: &'a mut String) -> Self { + TextField { + widget, + text, + multiline: false, + hint: None, + label: None, + width: None, + select_on_focus: false, + begin_edit: false, + frame: true, + font: None, + color: None, + align: None, + margin: None, + rows: None, + } + } + + /// A multi-line field: Return starts a new line; leaving the field ends the edit. + pub fn multiline(widget: &'a str, text: &'a mut String) -> Self { + TextField { multiline: true, ..Self::singleline(widget, text) } + } + + /// Grey text shown while the field is empty. + pub fn hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// The name a screen reader announces for the field (its visible caption); the hint + /// stands in when there is none. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn width(mut self, width: f32) -> Self { + self.width = Some(width); + self + } + + /// How many lines a multi-line field shows. + pub fn rows(mut self, rows: usize) -> Self { + self.rows = Some(rows); + self + } + + /// Draw the field's own box (on by default); off where the caller draws one around it. + pub fn frame(mut self, on: bool) -> Self { + self.frame = on; + self + } + + pub fn font(mut self, font: egui::FontId) -> Self { + self.font = Some(font); + self + } + + pub fn text_color(mut self, color: egui::Color32) -> Self { + self.color = Some(color); + self + } + + pub fn horizontal_align(mut self, align: egui::Align) -> Self { + self.align = Some(align); + self + } + + pub fn margin(mut self, margin: egui::Margin) -> Self { + self.margin = Some(margin); + self + } + + /// Select all the text when the field takes focus, so typing replaces it (names, numbers). + pub fn select_on_focus(mut self, on: bool) -> Self { + self.select_on_focus = on; + self + } + + /// Initialize a newly revealed value editor before its first input is processed. + pub(crate) fn begin_edit(mut self, on: bool) -> Self { + self.begin_edit = on; + 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); + 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 + let pass = ui.ctx().cumulative_pass_nr(); + if memo.pass.checked_add(1) != Some(pass) { + memo = Memo::default(); + } + memo.pass = pass; + if self.begin_edit { + memo.before = Some(self.text.clone()); + memo.select_on_release = false; + ui.memory_mut(|m| m.request_focus(id)); + select_all(ui.ctx(), id, self.text); + } + // a choice from the menu last frame: the field takes the focus back and does it, with + // egui's own handling of the clipboard events (a password is never copied) + if let Some(action) = memo.action.take() { + ui.memory_mut(|m| m.request_focus(id)); + match action { + Action::Cut => ui.input_mut(|i| i.events.push(egui::Event::Cut)), + Action::Copy => ui.input_mut(|i| i.events.push(egui::Event::Copy)), + // only the host can read the clipboard: it answers with a paste event + Action::Paste => ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste), + Action::SelectAll => select_all(ui.ctx(), id, self.text), + Action::Refocus => {} + } + } + // A field focused by the previous pass (a dialog asks after drawing it) must already + // select its text on the pass that carries the first keystroke: egui processes text + // inside `ui.add`, before the after-add branch below could select, leaving the new text + // inserted into the old. A press may go on to drag a selection, so it keeps the + // after-add path; an edit already under way keeps its pre-edit text for Esc. + let focused_at_start = ui.memory(|m| m.has_focus(id)); + if focused_at_start && memo.before.is_none() && self.select_on_focus && !ui.input(|i| i.pointer.any_down()) { + memo.before = Some(self.text.clone()); + select_all(ui.ctx(), id, self.text); + } + 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); + if let Some(hint) = self.hint { + edit = edit.hint_text(hint); + } + if let Some(w) = self.width { + edit = edit.desired_width(w); + } + if !self.frame { + edit = edit.frame(egui::Frame::NONE); + } + if let Some(font) = self.font { + edit = edit.font(font); + } + if let Some(color) = self.color { + edit = edit.text_color(color); + } + if let Some(align) = self.align { + edit = edit.align(egui::Align2([align, egui::Align::Min])); + } + if let Some(margin) = self.margin { + edit = edit.margin(margin); + } + if let Some(rows) = self.rows { + edit = edit.desired_rows(rows); + } + let (escape, enter) = ui.input(|i| (i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Enter))); + // a right-click places the cursor as a left one does: the selection the menu acts on + // would be gone + let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary)); + let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range()); + let mut response = ui.add(edit); + // screen readers: the field by its caption, even once it holds text (egui names it by + // nothing but its contents) + if let Some(name) = name.filter(|n| !n.trim().is_empty()) { + ui.ctx().accesskit_node_builder(response.id, |node| node.set_label(name)); + } + // The menu may take focus on a primary-button release. egui collapses a + // TextEdit selection when focus leaves; retain the range our own menu acts on. + if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) { + let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + state.cursor.set_char_range(selection); + state.store(ui.ctx(), id); + } + crate::widgets::register(ui, self.widget, response.rect); + // egui's own focus: `Response::has_focus` is also false while the window is in the + // background (another app in front), which neither ends nor restarts an edit + let focused = ui.memory(|m| m.has_focus(id)); + // The opening press may place a cursor; keep the original selected, never replacement text. + if self.begin_edit && focused && !response.changed() { + select_all(ui.ctx(), id, self.text); + } + + // an edit not under way (none, or one cut short when the field went away) is forgotten + let in_menu = memo.away || memo.menu_open || memo.action.is_some(); + if !focused && !response.lost_focus() && !in_menu { + memo.before = None; + } + // the start of an edit (not the focus coming back from the menu) + let pressing = ui.input(|i| i.pointer.any_down()); + if focused && memo.before.is_none() { + memo.before = Some(self.text.clone()); + if self.select_on_focus { + // a press that took the focus may go on to drag a selection: wait for its release + if pressing { + memo.select_on_release = true; + } else { + select_all(ui.ctx(), id, self.text); + } + } + } + if memo.select_on_release && !pressing { + memo.select_on_release = false; + // a plain click selects everything; a drag keeps what it selected + if focused && !has_selection(ui.ctx(), id) { + select_all(ui.ctx(), id, self.text); + } + } + let menu_was_open = memo.menu_open; + // a click on a greyed-out item does nothing, as in a native menu: only a choice or a click + // outside closes it (not the right-click that moves the menu within the field, #1877) + memo.menu_open = egui::Popup::context_menu(&response) + .close_behavior(crate::widgets::context_menu_close(&response, egui::PopupCloseBehavior::CloseOnClickOutside)) + .show(|ui| menu(ui, self.widget, id, self.text, &mut memo.action)) + .is_some(); + + let mut ending = None; + let close_menu = |ui: &Ui| egui::Popup::close_id(ui.ctx(), egui::Popup::default_response_id(&response)); + // egui reports a lost focus on two frames: only an edit under way ends + if response.lost_focus() && memo.before.is_some() { + if escape && menu_was_open { + // Esc closes only the menu, as in native menus: the field takes the focus back + memo.action = Some(Action::Refocus); + } else if escape { + if let Some(before) = memo.before.take() + && *self.text != before + { + *self.text = before; + response.mark_changed(); + } + ending = Some(Ending::Escape); + } else if enter && !self.multiline { + ending = Some(Ending::Return); + close_menu(ui); + } else if menu_was_open || memo.menu_open || memo.action.is_some() { + // the click went to the menu: the edit goes on once the menu is done with + memo.away = true; + } else { + ending = Some(Ending::Away); + } + } else if memo.away && escape && memo.action.is_none() { + // Esc with the focus in the menu (Tab took it there) closes only the menu + memo.action = Some(Action::Refocus); + memo.away = false; + } else if memo.away && !memo.menu_open && memo.action.is_none() && !focused { + // the menu closed without a choice: the click outside it left the field + ending = Some(Ending::Away); + } + if focused { + memo.away = false; + } + if ending.is_some() { + memo = Memo { pass, ..Memo::default() }; + } + let editing = ending.is_none() && (focused || memo.away || memo.menu_open || memo.action.is_some()); + ui.data_mut(|d| d.insert_temp(id.with("memo"), memo)); + TextFieldResponse { response, ending, editing } + } +} + +/// What a field remembers between frames. +#[derive(Clone, Default)] +struct Memo { + /// The text when the edit started, for Esc. + before: Option, + /// The context menu was open at the end of the frame. + menu_open: bool, + /// The menu's choice, done next frame. + action: Option, + /// The focus left for the menu: the edit ends if the menu closes without a choice. + away: bool, + /// The field took the focus with select-on-focus under a press: select all on its release, + /// unless it dragged a selection. + select_on_release: bool, + /// The frame (egui pass) the field was last drawn in. + pass: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Action { + Cut, + Copy, + Paste, + SelectAll, + /// Only take the focus back (Esc closed the menu). + Refocus, +} + +fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) { + let mut state = egui::text_edit::TextEditState::load(ctx, id).unwrap_or_default(); + let end = egui::text::CCursor::new(text.chars().count()); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); + state.store(ctx, id); +} + +/// Some text is selected in the field. +fn has_selection(ctx: &egui::Context, id: egui::Id) -> bool { + egui::text_edit::TextEditState::load(ctx, id).and_then(|s| s.cursor.char_range()).is_some_and(|r| !r.is_empty()) +} + +/// The field's context menu: Cut, Copy, Paste, Select All, with the keyboard's shortcuts beside them. +fn menu(ui: &mut Ui, widget: &str, id: egui::Id, text: &str, action: &mut Option) { + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let selected = has_selection(ui.ctx(), id); + let items = [ + (Action::Cut, "cut", "Cut", "Cmd+X", selected), + (Action::Copy, "copy", "Copy", "Cmd+C", selected), + // a browser only pastes from its own paste event, never on request + (Action::Paste, "paste", "Paste", "Cmd+V", !cfg!(target_arch = "wasm32")), + (Action::SelectAll, "selectAll", "Select All", "Cmd+A", !text.is_empty()), + ]; + for (a, key, label, shortcut, enabled) in items { + if a == Action::Paste && cfg!(target_arch = "wasm32") { + continue; + } + if a == Action::SelectAll { + ui.separator(); + } + let button = egui::Button::new(crate::i18n::tr(label)).shortcut_text(crate::menubar::shortcut_text(shortcut, mac)); + let r = ui.add_enabled(enabled, button); + crate::widgets::register(ui, format!("{widget}:{key}"), r.rect); + if r.clicked() { + *action = Some(a); + ui.close(); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/widgets/text_field/tests.rs b/crates/ui-egui/src/widgets/text_field/tests.rs new file mode 100644 index 000000000..0c4e3c3d3 --- /dev/null +++ b/crates/ui-egui/src/widgets/text_field/tests.rs @@ -0,0 +1,422 @@ +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect}; + +use super::*; +use crate::headless::HeadlessView; + +const FIELD: &str = "cityField"; + +/// A field alone in a window, with a blank area beside it to click away to. +struct Rig { + view: HeadlessView, + text: String, + select_on_focus: bool, + /// The field is on screen (a dialog that holds it may close). + shown: bool, + /// The app's window has the system's focus (the user may switch to another app). + window_focused: bool, + /// The field said an edit is under way, last frame. + editing: bool, + endings: Vec, + widgets: Vec<(String, Rect)>, + time: f64, +} + +impl Rig { + fn new(text: &str) -> Self { + let mut rig = Rig { + view: HeadlessView::new(), + text: text.to_string(), + select_on_focus: false, + shown: true, + window_focused: true, + editing: false, + endings: vec![], + widgets: vec![], + time: 0.0, + }; + rig.frame(vec![]); + rig + } + + fn frame(&mut self, events: Vec) { + self.frame_with(events, false); + } + + /// A frame that asks for the field's focus after drawing it, as a dialog does, so the + /// focus takes effect on the next frame. + fn frame_with(&mut self, events: Vec, focus_after: bool) { + self.time += 1.0 / 60.0; + let mut raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events); + raw.focused = self.window_focused; + let (text, select, shown, endings, editing) = (&mut self.text, self.select_on_focus, self.shown, &mut self.endings, &mut self.editing); + self.view.run(raw, |ui| { + ui.add_space(20.0); + if !shown { + return; + } + let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui); + endings.extend(r.ending); + *editing = r.editing; + if focus_after { + ui.memory_mut(|m| m.request_focus(id(FIELD))); + } + }); + self.widgets = crate::widgets::take_registry(&self.view.ctx); + } + + /// A few frames, for menus to open and requests to come back. + fn settle(&mut self) { + for _ in 0..4 { + self.frame(vec![]); + } + } + + fn rect(&self, id: &str) -> Option { + self.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) + } + + fn click_at(&mut self, at: Pos2, button: PointerButton) { + // far apart in time from the last click: two clicks never make a double-click + self.time += 1.0; + let press = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }; + self.frame(vec![Event::PointerMoved(at), press(true)]); + self.frame(vec![press(false)]); + self.settle(); + } + + fn click(&mut self, id: &str, button: PointerButton) { + let r = self.rect(id).unwrap_or_else(|| panic!("no {id} on screen: {:?}", self.widgets)); + self.click_at(r.center(), button); + } + + fn key(&mut self, key: Key, modifiers: Modifiers) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + self.frame(vec![ev(true), ev(false)]); + self.settle(); + } + + fn type_text(&mut self, text: &str) { + self.frame(vec![Event::Text(text.to_string())]); + self.settle(); + } + + fn focused(&self) -> bool { + self.view.ctx.memory(|m| m.focused()).is_some() + } + + /// Click into the field and select all its text with ⌘A. + fn select_all_by_keys(&mut self) { + self.click(FIELD, PointerButton::Primary); + self.key(Key::A, Modifiers::COMMAND); + } + + /// Right-click the field, then pick `item` from its menu. + fn menu(&mut self, item: &str) { + self.click(FIELD, PointerButton::Secondary); + self.click(&format!("{FIELD}:{item}"), PointerButton::Primary); + } +} + +/// Right-click offers Cut, Copy, Paste and Select All. +#[test] +fn right_click_offers_cut_copy_paste_and_select_all() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Secondary); + for item in ["cut", "copy", "paste", "selectAll"] { + assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: {:?}", rig.widgets); + } +} + +/// Copy in the menu puts the selected text on the clipboard and leaves the field as it was, +/// still being edited. +#[test] +fn copy_in_the_menu_copies_the_selection() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.menu("copy"); + assert_eq!(rig.view.clipboard, "Lisbon"); + assert_eq!(rig.text, "Lisbon"); + assert!(rig.focused(), "still editing"); + assert_eq!(rig.endings, vec![], "using the menu is not leaving the field"); +} + +/// Cut in the menu puts the selected text on the clipboard and takes it out of the field. +#[test] +fn cut_in_the_menu_moves_the_selection_to_the_clipboard() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.menu("cut"); + assert_eq!(rig.view.clipboard, "Lisbon"); + assert_eq!(rig.text, ""); +} + +/// Paste in the menu puts the clipboard's text in place of the selection. +#[test] +fn paste_in_the_menu_replaces_the_selection() { + let mut rig = Rig::new("Lisbon"); + rig.view.clipboard = "Porto".into(); + rig.select_all_by_keys(); + rig.menu("paste"); + assert_eq!(rig.text, "Porto"); +} + +/// Select All in the menu selects the whole text: what is typed next replaces it. +#[test] +fn select_all_in_the_menu_selects_the_whole_text() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.menu("selectAll"); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto"); +} + +/// #1877: a right-click elsewhere in the field while its menu is open moves the menu there +/// (it flashed at the new spot and closed). +#[test] +fn right_clicking_again_moves_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Secondary); + let before = rig.rect(&format!("{FIELD}:cut")).expect("the menu"); + let rect = rig.rect(FIELD).unwrap(); + let at = egui::pos2(rect.left() + 10.0, rect.center().y); + rig.click_at(at, PointerButton::Secondary); + let after = rig.rect(&format!("{FIELD}:cut")).unwrap_or_else(|| panic!("the menu is still open: {:?}", rig.widgets)); + assert!(after.left() < before.left(), "the menu moved to the pointer: {after:?} (was {before:?})"); + assert!(rig.endings.is_empty(), "the edit goes on"); +} + +#[test] +fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.click(FIELD, PointerButton::Secondary); + let rect = rig.rect(FIELD).unwrap(); + rig.click_at(egui::pos2(rect.left() + 2.0, rect.center().y), PointerButton::Primary); + let range = egui::text_edit::TextEditState::load(&rig.view.ctx, id(FIELD)).unwrap().cursor.char_range().unwrap(); + assert!(range.is_empty(), "a click back in the field places a caret: {range:?}"); + rig.type_text("X"); + assert_eq!(rig.text, "XLisbon"); + assert!(rig.endings.is_empty(), "dismissing into the field keeps the edit open"); +} + +/// With nothing selected, Cut and Copy are greyed out: clicking one does nothing, as in a native +/// menu, and neither closes the menu nor ends the edit. +#[test] +fn greyed_out_items_do_nothing() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.click(FIELD, PointerButton::Secondary); + for item in ["cut", "copy"] { + rig.click(&format!("{FIELD}:{item}"), PointerButton::Primary); + assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: the menu is still open"); + } + assert_eq!((rig.text.as_str(), rig.view.clipboard.as_str()), ("Lisbon", "")); + assert_eq!(rig.endings, vec![], "still editing"); + rig.menu("selectAll"); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto", "and the edit goes on"); +} + +/// Esc gives back the text from before the edit and says the edit was cancelled. +#[test] +fn escape_gives_back_the_text_from_before_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + assert_eq!(rig.text, "Rome"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Lisbon"); + assert_eq!(rig.endings, vec![Ending::Escape]); +} + +/// Return ends the edit and keeps the text. +#[test] +fn return_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.key(Key::Enter, Modifiers::NONE); + assert_eq!(rig.text, "Rome"); + assert_eq!(rig.endings, vec![Ending::Return]); +} + +/// Return with the menu open ends the edit as Return does, and closes the menu. +#[test] +fn return_with_the_menu_open_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Enter, Modifiers::NONE); + assert_eq!(rig.endings, vec![Ending::Return]); + assert_eq!(rig.text, "Rome"); + assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); +} + +/// Esc with the menu open closes only the menu, as in native menus: the edit goes on, and a +/// second Esc gives back the text. +#[test] +fn escape_with_the_menu_open_closes_only_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Escape, Modifiers::NONE); + assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); + assert!(rig.focused()); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); +} + +/// Esc after Tab took the focus into the open menu also closes only the menu. +#[test] +fn escape_from_inside_the_menu_closes_only_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Tab, Modifiers::NONE); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); + assert!(rig.focused()); +} + +/// Clicking elsewhere ends the edit and keeps the text. +#[test] +fn clicking_away_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); + assert_eq!(rig.text, "Rome"); + assert_eq!(rig.endings, vec![Ending::Away]); +} + +/// Opening the menu and closing it without a choice (a click outside) leaves the field. +#[test] +fn dismissing_the_menu_leaves_the_field() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.click(FIELD, PointerButton::Secondary); + rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); + assert!(!rig.focused()); + assert_eq!(rig.endings, vec![Ending::Away]); +} + +/// A field that selects on focus: clicking into it selects everything, so typing replaces it. +#[test] +fn a_field_that_selects_on_focus_is_retyped_whole() { + let mut rig = Rig::new("Lisbon"); + rig.select_on_focus = true; + rig.click(FIELD, PointerButton::Primary); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto"); +} + +/// A field focused by the pass before (a dialog asks for focus after drawing it) selects its +/// text before egui handles the first keystroke of the pass the focus takes effect, so that +/// keystroke replaces the text instead of being inserted into it. +#[test] +fn text_typed_on_the_first_focused_pass_replaces_the_whole_text() { + let mut rig = Rig::new("LightCraft Library"); + rig.select_on_focus = true; + // the dialog draws the field, then asks for its focus: effective on the next pass + rig.frame_with(vec![], true); + assert!(rig.view.ctx.memory(|m| m.has_focus(id(FIELD))), "focused for the next pass"); + // the pass the focus takes effect already carries the text + rig.frame(vec![Event::Text("Fresh".into())]); + assert_eq!(rig.text, "Fresh", "the first keystroke replaced the selected text"); + // the text from before the edit is still what Esc gives back + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "LightCraft Library"); + assert_eq!(rig.endings, vec![Ending::Escape]); +} + +/// Esc after using the menu still gives back the text from before the whole edit. +#[test] +fn escape_after_the_menu_gives_back_the_original_text() { + let mut rig = Rig::new("Lisbon"); + rig.view.clipboard = "Porto".into(); + rig.select_all_by_keys(); + rig.menu("paste"); + assert_eq!(rig.text, "Porto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Lisbon"); +} + +/// A field that goes away in the middle of an edit (its dialog closed) starts afresh when it +/// comes back: Esc then gives back the text it came back with. +#[test] +fn an_edit_cut_short_is_forgotten() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.shown = false; + rig.settle(); + rig.text = "Porto".into(); + rig.shown = true; + rig.settle(); + rig.select_all_by_keys(); + rig.type_text("Kyoto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Porto"); +} + +/// Switching to another app and back in the middle of an edit carries on with it: the field +/// is still being edited meanwhile, and Esc still gives back the text from before. +#[test] +fn switching_apps_carries_on_with_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.window_focused = false; + rig.settle(); + assert!(rig.editing, "still being edited while the app is in the background"); + assert_eq!(rig.endings, vec![]); + rig.window_focused = true; + rig.settle(); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); +} + +/// A field that goes away while its menu is open starts afresh too when it comes back. +#[test] +fn a_field_gone_with_its_menu_open_starts_afresh() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + // a click on the greyed-out Cut takes the focus into the menu + rig.click(&format!("{FIELD}:cut"), PointerButton::Primary); + rig.shown = false; + rig.settle(); + rig.text = "Porto".into(); + rig.shown = true; + rig.settle(); + assert!(!rig.editing, "no edit under way"); + assert_eq!(rig.endings, vec![], "and none ended: it was cut short"); + rig.select_all_by_keys(); + rig.type_text("Kyoto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Porto"); +} + +/// A field that selects on focus still lets a drag into it select part of the text, as a +/// browser's address bar does: only a plain click selects everything. +#[test] +fn a_drag_into_a_field_that_selects_on_focus_selects_what_it_covers() { + let mut rig = Rig::new("Lisbon Portugal Europe Atlantic Ocean"); + rig.select_on_focus = true; + let r = rig.rect(FIELD).expect("the field"); + let (from, to) = (r.center(), egui::pos2(r.right() + 40.0, r.center().y)); + let press = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + rig.frame(vec![Event::PointerMoved(from), press(from, true)]); + for i in 1..=5 { + rig.frame(vec![Event::PointerMoved(from + (to - from) * (i as f32 / 5.0))]); + } + rig.frame(vec![press(to, false)]); + rig.settle(); + rig.type_text("X"); + assert!(rig.text.starts_with("Lisbon") && rig.text.ends_with('X') && rig.text != "X", "{:?}", rig.text); +} diff --git a/crates/ui-egui/src/window_place.rs b/crates/ui-egui/src/window_place.rs index e88cca9a1..c37f6064b 100644 --- a/crates/ui-egui/src/window_place.rs +++ b/crates/ui-egui/src/window_place.rs @@ -319,601 +319,4 @@ impl WindowWatch { } #[cfg(test)] -mod tests { - use super::*; - - const SCREEN: [f32; 2] = [2560.0, 1440.0]; - - fn window(size: [f32; 2]) -> Seen { - Seen { size: Some(size), screen: Some(SCREEN), ..Default::default() } - } - - fn place(size: [f32; 2], zoomed: bool) -> WindowPlace { - WindowPlace { size, zoomed } - } - - /// Given a window that is resized and comes to rest, when it is watched, then its new size - /// is remembered once it has rested for a moment, and none of the sizes it passed through. - #[test] - fn a_window_is_remembered_at_the_size_it_comes_to_rest_at() { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - let mut remembered = None; - for (i, width) in [1000.0, 1100.0, 1200.0, 1300.0].into_iter().enumerate() { - remembered = watch.note(remembered, &window([width, 800.0]), i as f64 * 0.1); - assert_eq!(remembered, None, "passing through {width}"); - assert!(watch.waits(i as f64 * 0.1).is_some(), "and the app looks again"); - } - remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.5); - assert_eq!(remembered, None, "not yet at rest"); - // (it has been this size since 0.3: the app looks again just after 0.8) - let wait = watch.waits(0.5).map(|w| w.as_secs_f64()).unwrap(); - assert!((wait - 0.35).abs() < 1e-6, "{wait}"); - remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.9); - assert_eq!(remembered, Some(place([1300.0, 800.0], false))); - assert!(watch.waits(0.9).is_none()); - // as it is: nothing to wait for - assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 5.0), remembered); - assert!(watch.waits(5.0).is_none()); - } - } - - /// Given a window at rest that is zoomed (the top bar double-clicked), when it is watched - /// where the window system says so, then it is remembered as zoomed at once, with the size - /// it had before, though it grew through other sizes on the way; un-zoomed, it is no longer - /// zoomed at once and keeps that size. - #[test] - fn a_zoomed_window_keeps_the_size_it_goes_back_to() { - let mut watch = WindowWatch::told_live(true); - let rest = place([1300.0, 800.0], false); - let mut remembered = Some(rest); - for (i, width) in [1600.0, 2000.0, 2400.0].into_iter().enumerate() { - remembered = watch.note(remembered, &window([width, 1300.0]), 10.0 + i as f64 * 0.05); - assert_eq!(remembered, Some(rest)); - } - let zoomed = Seen { zoomed: true, ..window([2560.0, 1412.0]) }; - remembered = watch.note(remembered, &zoomed, 10.2); - assert_eq!(remembered, Some(place([1300.0, 800.0], true))); - assert_eq!(watch.note(remembered, &zoomed, 30.0), Some(place([1300.0, 800.0], true)), "however long it stays zoomed"); - assert!(watch.waits(30.0).is_none()); - remembered = watch.note(remembered, &window([2000.0, 1100.0]), 31.0); - assert_eq!(remembered, Some(rest), "no longer zoomed, and the size it had"); - assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 31.2), Some(rest)); - // zoomed before anything was remembered: it opens zoomed, and goes back to the usual size - assert_eq!(WindowWatch::told_live(true).note(None, &zoomed, 0.0), Some(place(DEFAULT_SIZE, true))); - // the system's word is the answer there: a window as large as its screen is a window - assert_eq!(WindowWatch::told_live(true).note(Some(rest), &window([2560.0, 1335.0]), 0.0).map(|p| p.zoomed), Some(false)); - } - - /// Given macOS, where the window system says whether the window is zoomed only as it opens - /// and when the app zooms it itself: when a window that opened zoomed is resized by its edge, - /// then it is a window again, remembered at the size it was given, though the system's word - /// still says zoomed; zoomed once more, it is zoomed. - #[test] - fn a_zoomed_window_resized_by_its_edge_is_a_window_again_where_the_system_does_not_say() { - let mut watch = WindowWatch::told_live(false); - let rest = place([1300.0, 800.0], true); - let zoomed = Seen { zoomed: true, ..window([2560.0, 1335.0]) }; - let mut remembered = watch.note(Some(rest), &zoomed, 0.0); - assert_eq!(remembered, Some(rest)); - assert!(watch.waits(0.0).is_some(), "the size it is zoomed at is still to be seen at rest"); - remembered = watch.note(remembered, &zoomed, 0.6); - assert_eq!(remembered, Some(rest)); - assert!(watch.waits(0.6).is_none()); - assert_eq!(watch.note(remembered, &zoomed, 60.0), Some(rest), "however long it stays zoomed"); - // (a size goes through the screen's pixels and back: a point off is the same size) - assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([2559.0, 1336.0]) }, 60.5), Some(rest)); - // its right edge dragged in a little, far less than a Dock is wide; the word is unchanged - let dragged = Seen { zoomed: true, ..window([2500.0, 1335.0]) }; - remembered = watch.note(remembered, &dragged, 61.0); - assert_eq!(remembered, Some(place([1300.0, 800.0], false)), "not zoomed any more"); - remembered = watch.note(remembered, &dragged, 61.6); - assert_eq!(remembered, Some(place([2500.0, 1335.0], false)), "and at the size it was given"); - assert!(watch.waits(61.6).is_none()); - // zoomed again from the Window menu: back at the size it is zoomed at - assert_eq!(watch.note(remembered, &zoomed, 70.0), Some(place([2500.0, 1335.0], true))); - } - - /// Given macOS, when the app zooms the window itself (the top bar double-clicked), then the - /// system says so at once and the window is zoomed through all the sizes it grows through, - /// with the size it had, wherever the Dock is; un-zoomed the same way, it is a window again - /// at that size. - #[test] - fn a_window_the_app_zooms_is_zoomed_at_once_where_the_system_does_not_say() { - let mut watch = WindowWatch::told_live(false); - let rest = place([1300.0, 800.0], false); - let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - // (a Dock at the left: zoomed, the window is narrower than its screen) - for (i, width) in [1300.0, 1700.0, 2100.0, 2410.0].into_iter().enumerate() { - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 1415.0]) }, 1.0 + i as f64 * 0.05); - assert_eq!(remembered, Some(place([1300.0, 800.0], true)), "growing through {width}"); - } - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2410.0, 1415.0]) }, 2.0); - assert_eq!(remembered, Some(place([1300.0, 800.0], true))); - assert!(watch.waits(2.0).is_none()); - for (i, width) in [2100.0, 1700.0, 1300.0].into_iter().enumerate() { - remembered = watch.note(remembered, &window([width, 800.0]), 3.0 + i as f64 * 0.05); - assert_eq!(remembered, Some(rest), "shrinking through {width}"); - } - assert!(watch.waits(3.1).is_none(), "back at the size it had: nothing to wait for"); - } - - /// Given macOS and a window zoomed from the Window menu (the system's word stays "not - /// zoomed"), when it is seen to come to fill its screen from side to side, then it is zoomed, - /// with the size it had, and zoomed back it is a window at that size again. A window that - /// only grows large is not zoomed, on any screen. - #[test] - fn a_window_that_comes_to_fill_its_screen_is_zoomed_where_the_system_does_not_say() { - let rest = place([1300.0, 800.0], false); - // a window of 1300 × 800 on `screen` that is next seen at `size` - let zoomed_on = |screen: [f32; 2], size: [f32; 2]| { - let mut watch = WindowWatch::told_live(false); - let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; - watch.note(Some(rest), &seen([1300.0, 800.0]), 0.0); - watch.note(Some(rest), &seen(size), 0.2).map(|p| p.zoomed) - }; - // under the menu bar, with the Dock hidden, small, or as large as it gets - for (screen, size) in [ - ([2560.0, 1440.0], [2560.0, 1415.0]), - ([2560.0, 1440.0], [2560.0, 1335.0]), - ([2560.0, 1440.0], [2559.0, 1335.0]), - ([1728.0, 1117.0], [1728.0, 1084.0]), - ([1512.0, 982.0], [1512.0, 794.0]), - ] { - assert_eq!(zoomed_on(screen, size), Some(true), "{size:?} on {screen:?}"); - } - // the usual size on a laptop's screen, windows sized by hand to nearly the screen, one - // that is a little narrower than it, and one as wide as it and far from as tall - for (screen, size) in [ - ([1728.0, 1117.0], DEFAULT_SIZE), - ([1710.0, 1107.0], DEFAULT_SIZE), - ([1512.0, 982.0], [1400.0, 820.0]), - ([1470.0, 956.0], [1340.0, 780.0]), - ([2560.0, 1440.0], [2430.0, 1270.0]), - ([2560.0, 1440.0], [2300.0, 1200.0]), - ([2560.0, 1440.0], [2550.0, 1415.0]), - ([2560.0, 1440.0], [2560.0, 1190.0]), - ] { - assert_eq!(zoomed_on(screen, size), Some(false), "{size:?} on {screen:?}"); - } - let mut watch = WindowWatch::told_live(false); - let filling = window([2560.0, 1335.0]); - let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - remembered = watch.note(remembered, &window([2000.0, 1100.0]), 0.9); - remembered = watch.note(remembered, &filling, 1.0); - assert_eq!(remembered, Some(place([1300.0, 800.0], true))); - assert_eq!(watch.note(remembered, &filling, 9.0), remembered, "and its own size is not written over"); - remembered = watch.note(remembered, &window([1900.0, 1000.0]), 10.0); - assert_eq!(remembered, Some(rest), "zoomed back: a window, at the size it had"); - assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 10.2), Some(rest)); - assert!(watch.waits(10.2).is_none()); - // not told apart: zoomed from the Window menu beside a Dock at the side, it is remembered - // as a window of that size - let mut watch = WindowWatch::told_live(false); - let beside_a_dock = window([2410.0, 1415.0]); - watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - watch.note(Some(rest), &beside_a_dock, 0.3); - assert_eq!(watch.note(Some(rest), &beside_a_dock, 0.9), Some(place([2410.0, 1415.0], false))); - // without a screen's size to go by, the system's word is all there is - let no_screen = Seen { size: Some([2560.0, 1335.0]), screen: None, zoomed: true, ..Default::default() }; - assert_eq!(WindowWatch::told_live(false).note(Some(rest), &no_screen, 0.0), Some(place([1300.0, 800.0], true))); - } - - /// Given macOS and a window that was left as wide as its screen and a little shorter (its - /// lower edge dragged up after a zoom), when the app opens it at that size, then it is a - /// window of that size, launch after launch: filling the screen is no sign of a zoom unless - /// the window was seen to come to it. Dragged shorter still, it is remembered at that. - #[test] - fn a_window_that_opens_as_wide_as_its_screen_is_a_window_where_the_system_does_not_say() { - for (screen, size) in [([1728.0, 1117.0], [1728.0, 1054.0]), ([1512.0, 982.0], [1512.0, 760.0]), ([3440.0, 1440.0], [3440.0, 1200.0])] { - let left = place(size, false); - let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; - let mut watch = WindowWatch::told_live(false); - for frame in 0..5 { - assert_eq!(watch.note(Some(left), &seen(size), f64::from(frame) * 0.3), Some(left), "{size:?} on {screen:?}"); - } - let shorter = [size[0], size[1] - 20.0]; - watch.note(Some(left), &seen(shorter), 2.0); - assert_eq!(watch.note(Some(left), &seen(shorter), 2.6), Some(place(shorter, false))); - } - // the same after a double-click on the top bar that changed nothing (the system's word was - // stale, and is now "not zoomed"): still the window it is - let mut watch = WindowWatch::told_live(false); - let screen = [1728.0, 1117.0]; - let left = place([1728.0, 1054.0], false); - watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 0.0); - watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 1.0); - // (taken for zoomed on the system's word, until that word is taken back) - let unsaid = Seen { size: Some(left.size), screen: Some(screen), zoomed: false, ..Default::default() }; - assert_eq!(watch.note(Some(left), &unsaid, 2.0), Some(left)); - assert_eq!(watch.note(Some(left), &unsaid, 9.0), Some(left)); - } - - /// Given macOS and a zoomed window, when it is zoomed back from the Window menu (or resized by - /// its edge) before it was ever seen at rest, then it is a window at once, and the size it - /// comes to rest at is the window's, not taken for the size it is zoomed at. - #[test] - fn a_window_zoomed_back_at_once_is_a_window_where_the_system_does_not_say() { - let mut watch = WindowWatch::told_live(false); - let rest = place([1240.0, 820.0], true); - let mut remembered = watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); - assert_eq!(remembered, Some(rest)); - for (i, width) in [2200.0, 1700.0, 1240.0, 1240.0, 1240.0].into_iter().enumerate() { - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 820.0]) }, 0.3 + i as f64 * 0.05); - assert_eq!(remembered, Some(place([1240.0, 820.0], false)), "{width}"); - } - assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([1240.0, 820.0]) }, 60.0), Some(place([1240.0, 820.0], false))); - assert!(watch.waits(60.0).is_none()); - } - - /// Given macOS and a zoomed window, when its screen changes (the display unplugged, another - /// resolution) and it is fitted to the new one, then it is still zoomed and keeps the size - /// it goes back to. Moved to a larger screen that it does not fill, it is a window. - #[test] - fn a_zoomed_window_is_still_zoomed_on_another_screen_where_the_system_does_not_say() { - for said in [true, false] { - let rest = place([1240.0, 820.0], false); - let on = |screen: [f32; 2], size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), zoomed: said, ..Default::default() }; - let mut watch = WindowWatch::told_live(false); - let mut remembered = watch.note(Some(rest), &on([2560.0, 1440.0], [1240.0, 820.0]), 0.0); - if said { - // (the system's word as the window opened: a window; the app zooms it later) - remembered = watch.note(remembered, &Seen { zoomed: false, ..on([2560.0, 1440.0], [1240.0, 820.0]) }, 0.0); - } - for now in [1.0, 2.0] { - remembered = watch.note(remembered, &on([2560.0, 1440.0], [2560.0, 1415.0]), now); - } - assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "said: {said}"); - for now in [10.0, 10.6, 60.0] { - remembered = watch.note(remembered, &on([1728.0, 1117.0], [1728.0, 1084.0]), now); - assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "on the smaller screen at {now}, said: {said}"); - } - assert!(watch.waits(60.0).is_none()); - // dragged over to a screen it does not fill - remembered = watch.note(remembered, &on([3440.0, 1440.0], [1728.0, 1084.0]), 70.0); - assert_eq!(remembered.map(|p| p.zoomed), Some(false), "said: {said}"); - } - // seen zoomed in one frame only, and fitted to another screen long after: it had rested - let mut watch = WindowWatch::told_live(false); - let rest = place([1240.0, 820.0], true); - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); - let fitted = Seen { size: Some([1728.0, 1084.0]), screen: Some([1728.0, 1117.0]), zoomed: true, ..Default::default() }; - assert_eq!(watch.note(Some(rest), &fitted, 5.0), Some(rest)); - } - - /// Given a size the window has not rested at yet, when the window goes full screen or is - /// minimized, or reports no size, then nothing waits (the app is not woken again and again - /// for as long as that lasts), and back as a window its size is seen afresh. - #[test] - fn nothing_waits_while_the_window_is_full_screen_or_minimized() { - let rest = place([1300.0, 800.0], false); - for live in [true, false] { - for away in [ - Seen { full_screen: true, ..window(SCREEN) }, - Seen { minimized: true, ..window([160.0, 28.0]) }, - Seen { size: None, ..window(SCREEN) }, - ] { - let mut watch = WindowWatch::told_live(live); - watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); - assert!(watch.waits(1.0).is_some()); - assert_eq!(watch.note(Some(rest), &away, 1.1), Some(rest)); - assert!(watch.waits(1.1).is_none(), "{away:?}"); - assert_eq!(watch.note(Some(rest), &away, 900.0), Some(rest)); - assert!(watch.waits(900.0).is_none(), "{away:?}"); - assert_eq!(watch.note(Some(rest), &window([1500.0, 900.0]), 901.0), Some(rest)); - let mut remembered = Some(rest); - for now in [902.2, 902.8] { - remembered = watch.note(remembered, &window([1500.0, 900.0]), now); - } - assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?}"); - assert!(watch.waits(902.8).is_none()); - } - } - // (macOS: the same of a zoomed window's size that was still to be seen at rest) - for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { - let mut watch = WindowWatch::told_live(false); - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, 0.0); - assert!(watch.waits(0.0).is_some()); - watch.note(Some(rest), &away, 0.1); - assert!(watch.waits(0.1).is_none(), "{away:?}"); - } - } - - /// Given a zoomed window that was full screen, when it leaves full screen, then for a moment - /// it is not looked at (the system animates the way back, at sizes that are neither the - /// window's nor the zoomed one), the app looks again once that is over, and the window is - /// zoomed with the size it goes back to, as before. - #[test] - fn a_window_that_has_just_left_full_screen_is_not_looked_at() { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - let rest = place([1240.0, 820.0], true); - let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; - for now in [0.0, 1.0] { - assert_eq!(watch.note(Some(rest), &zoomed, now), Some(rest)); - } - assert_eq!(watch.note(Some(rest), &Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, 5.0), Some(rest)); - // on the way back the system no longer says "full screen", nor "zoomed" where its word is live - for now in [9.0, 9.3, 9.6, 9.9] { - assert_eq!(watch.note(Some(rest), &Seen { zoomed: !live, ..window(SCREEN) }, now), Some(rest), "at {now}"); - let wait = watch.waits(now).map(|w| w.as_secs_f64()).unwrap(); - assert!((wait - (10.05 - now)).abs() < 1e-6, "at {now}: looks again after the moment, {wait}"); - } - assert_eq!(watch.note(Some(rest), &zoomed, 10.1), Some(rest)); - assert_eq!(watch.note(Some(rest), &zoomed, 11.0), Some(rest)); - assert!(watch.waits(11.0).is_none()); - } - } - - /// Given macOS and a window dragged by its corner, when it reaches the screen's width and is - /// then dragged on, taller and little by little narrower again, and left narrower than the - /// screen, then it is a window at the size it was left at: a zoom only grows on its way, so - /// what gets narrower again is somebody's hand. - #[test] - fn a_window_dragged_wide_and_narrower_again_is_a_window_where_the_system_does_not_say() { - for per_frame in [1.0, 1.4] { - let screen = [1728.0, 1117.0]; - let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; - let rest = place([1600.0, 950.0], false); - let mut watch = WindowWatch::told_live(false); - let mut remembered = watch.note(Some(rest), &seen(rest.size), 0.0); - let mut size = [1728.0, 950.0]; - let mut now = 1.0; - for _ in 0..40 { - remembered = watch.note(remembered, &seen(size), now); - size = [size[0] - per_frame, size[1] + 3.0]; - now += 1.0 / 60.0; - } - for _ in 0..3 { - remembered = watch.note(remembered, &seen(size), now); - now += 0.3; - } - assert_eq!(remembered, Some(place(size, false)), "{per_frame} pt narrower a frame"); - } - // a zoomed window whose edge is dragged in slowly: a window once it is no longer the size it was zoomed at - let mut watch = WindowWatch::told_live(false); - let rest = place([1300.0, 800.0], true); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); - } - let mut remembered = Some(rest); - for frame in 1..=10 { - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2560.0 - frame as f32, 1335.0]) }, 2.0 + f64::from(frame) / 60.0); - } - assert_eq!(remembered.map(|p| p.zoomed), Some(false), "ten points narrower, a point at a time"); - // and one whose lower edge is dragged up a little: it still fills its screen, and is a window - let mut watch = WindowWatch::told_live(false); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); - } - let shorter = Seen { zoomed: true, ..window([2560.0, 1385.0]) }; - watch.note(Some(rest), &shorter, 2.0); - assert_eq!(watch.note(Some(rest), &shorter, 2.6), Some(place([2560.0, 1385.0], false))); - } - - /// Given macOS and a window that opened zoomed, when its edge is dragged in before it was - /// seen at rest (one side only gets smaller), or it is carried to a larger screen in that - /// moment, then it is a window; and a frame without the screen's size costs a zoomed window - /// nothing. - #[test] - fn a_zoomed_window_changed_before_it_rested_is_a_window_where_the_system_does_not_say() { - let rest = place([1240.0, 820.0], true); - let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; - for at in [0.1, 0.3] { - let mut watch = WindowWatch::told_live(false); - watch.note(Some(rest), &zoomed, 0.0); - let dragged = Seen { zoomed: true, ..window([2000.0, 1415.0]) }; - assert_eq!(watch.note(Some(rest), &dragged, at), Some(place([1240.0, 820.0], false)), "dragged at {at}"); - assert_eq!(watch.note(Some(rest), &dragged, at + 0.6), Some(place([2000.0, 1415.0], false))); - } - // carried to a larger screen, as large as it is, a moment after it opened: no frame came in between - for at in [0.2, 0.52, 0.9] { - let mut watch = WindowWatch::told_live(false); - watch.note(Some(rest), &zoomed, 0.0); - let carried = Seen { size: Some([2560.0, 1415.0]), screen: Some([5120.0, 2880.0]), zoomed: true, ..Default::default() }; - assert_eq!(watch.note(Some(rest), &carried, at).map(|p| p.zoomed), Some(false), "carried at {at}"); - } - // the screen's size missing for a frame - let mut watch = WindowWatch::told_live(false); - for now in [0.0, 1.0] { - watch.note(Some(rest), &zoomed, now); - } - assert_eq!(watch.note(Some(rest), &Seen { screen: None, ..zoomed }, 2.0), Some(rest)); - assert_eq!(watch.note(Some(rest), &zoomed, 2.1), Some(rest)); - } - - /// Given macOS and a window that opened zoomed and was then resized by hand (the system's - /// word still says zoomed), when it goes full screen and back, or reports no size for a - /// frame, then it is still the window it was: what was made out of it is not let go of for - /// the system's old word. - #[test] - fn a_window_made_out_is_not_taken_for_zoomed_again_on_the_systems_old_word() { - for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { - let mut watch = WindowWatch::told_live(false); - let rest = place([1240.0, 820.0], true); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); - } - let dragged = Seen { zoomed: true, ..window([1500.0, 900.0]) }; - let mut remembered = watch.note(Some(rest), &dragged, 2.0); - remembered = watch.note(remembered, &dragged, 2.6); - assert_eq!(remembered, Some(place([1500.0, 900.0], false))); - remembered = watch.note(remembered, &away, 5.0); - for now in [8.0, 8.6, 9.2, 30.0] { - remembered = watch.note(remembered, &dragged, now); - assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?} at {now}"); - } - } - } - - /// Given a platform where the system says whether the window is maximized, when a window - /// grows until it fills its screen, then it is a window all the same: only the system's - /// word counts there. And the watch the app makes goes by that word everywhere but on macOS. - #[test] - fn where_the_system_says_only_its_word_counts() { - let rest = place([1300.0, 800.0], false); - let mut watch = WindowWatch::told_live(true); - let mut remembered = Some(rest); - for (i, size) in [[1300.0, 800.0], [2000.0, 1100.0], [2560.0, 1335.0], [2560.0, 1335.0], [2560.0, 1335.0]].into_iter().enumerate() { - remembered = watch.note(remembered, &window(size), i as f64 * 0.4); - assert_eq!(remembered.map(|p| p.zoomed), Some(false)); - } - assert_eq!(remembered, Some(place([2560.0, 1335.0], false))); - // the app's own watch: opened zoomed and then seen at a hand size, the system's word unchanged - let mut watch = WindowWatch::default(); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); - } - let zoomed = watch.note(Some(rest), &Seen { zoomed: true, ..window([1500.0, 900.0]) }, 2.0).map(|p| p.zoomed); - assert_eq!(zoomed, Some(!cfg!(target_os = "macos")), "macOS goes by the size, the others by the word"); - } - - /// Given a window that left full screen a moment ago, when it is minimized or goes full - /// screen again, then nothing waits; and a window that covers its screen altogether (full - /// screen, though nothing says so) is not remembered as a size, however long it stays. - #[test] - fn nothing_waits_when_a_window_just_back_from_full_screen_goes_away_again() { - let rest = place([1300.0, 800.0], false); - for live in [true, false] { - for away in [Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { full_screen: true, ..window(SCREEN) }] { - let mut watch = WindowWatch::told_live(live); - watch.note(Some(rest), &Seen { full_screen: true, ..window(SCREEN) }, 1.0); - watch.note(Some(rest), &window([1300.0, 800.0]), 2.0); - assert!(watch.waits(2.0).is_some(), "the moment after full screen"); - for now in [2.1, 2.7, 60.0] { - assert_eq!(watch.note(Some(rest), &away, now), Some(rest)); - assert!(watch.waits(now).is_none(), "{away:?} at {now}"); - } - } - let mut watch = WindowWatch::told_live(live); - watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - for now in [1.0, 1.6, 2.2, 60.0] { - // (where the system does not say, a window that comes to cover its screen is zoomed) - assert_eq!(watch.note(Some(rest), &window(SCREEN), now).map(|p| p.size), Some(rest.size), "covering its screen at {now}"); - } - assert!(watch.waits(60.0).is_none()); - } - } - - /// Given a window smaller than the app's smallest (a window manager that does not keep to - /// it, a script), when it rests, then the smallest size is remembered, once: what is - /// remembered and what is seen agree from then on, and nothing waits. - #[test] - fn a_window_smaller_than_the_smallest_is_remembered_once() { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - let small = window([640.0, 700.0]); - let mut remembered = watch.note(Some(place([1300.0, 800.0], false)), &small, 0.0); - remembered = watch.note(remembered.and_then(WindowPlace::checked), &small, 0.6); - assert_eq!(remembered, Some(place([MIN_SIZE[0], 700.0], false))); - for frame in 0..20 { - let now = 1.0 + f64::from(frame) * 0.1; - assert_eq!(watch.note(remembered.and_then(WindowPlace::checked), &small, now), remembered); - assert!(watch.waits(now).is_none(), "frame {frame}"); - } - } - } - - /// Full screen and a minimized window are not sizes to open at: nothing is remembered of - /// them. Nor of a window that reports no size. - #[test] - fn full_screen_and_minimized_windows_are_not_remembered() { - let rest = place([1300.0, 800.0], false); - for seen in [ - Seen { full_screen: true, ..window(SCREEN) }, - Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, - Seen { minimized: true, ..window([160.0, 28.0]) }, - Seen { size: None, ..window([0.0, 0.0]) }, - ] { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - assert_eq!(watch.note(Some(rest), &seen, 0.0), Some(rest), "{seen:?}"); - assert_eq!(watch.note(Some(rest), &seen, 5.0), Some(rest), "{seen:?}"); - assert_eq!(watch.at_exit(Some(rest), 6.0), Some(rest)); - } - } - } - - /// Given a window that was resized, when the app quits before the size has rested its - /// moment, then the size is remembered if the window had been let go of, and not if it was - /// still changing with every frame (a zoom the system was animating). - #[test] - fn a_size_the_window_rested_at_is_remembered_when_the_app_quits() { - let rest = place([1300.0, 800.0], false); - let mut watch = WindowWatch::told_live(true); - watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); - assert_eq!(watch.at_exit(Some(rest), 1.3), Some(place([1500.0, 900.0], false))); - assert_eq!(watch.at_exit(Some(rest), 9.0), Some(rest), "said once"); - let mut watch = WindowWatch::told_live(true); - watch.note(Some(rest), &window([1500.0, 900.0]), 1.00); - watch.note(Some(rest), &window([1700.0, 1000.0]), 1.02); - assert_eq!(watch.at_exit(Some(rest), 1.04), Some(rest), "a size it was passing through"); - assert_eq!(WindowWatch::told_live(true).at_exit(None, 3.0), None); - } - - /// What egui holds of the window is read in the system's points: a size every platform - /// reports (the rectangle the app draws in), brought back from the interface's own scale. - #[test] - fn the_window_is_seen_in_the_systems_points() { - use egui::{Rect, ViewportInfo, pos2, vec2}; - let info = ViewportInfo { monitor_size: Some(vec2(1280.0, 720.0)), maximized: Some(true), fullscreen: Some(false), ..Default::default() }; - let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(650.0, 400.0)); - // (the interface at twice its size: egui's points are half the system's) - let seen = Seen::of(&info, content, 2.0); - assert_eq!(seen, Seen { size: Some([1300.0, 800.0]), screen: Some([2560.0, 1440.0]), zoomed: true, full_screen: false, minimized: false }); - let away = ViewportInfo { maximized: Some(false), fullscreen: Some(true), minimized: Some(true), ..Default::default() }; - let seen = Seen::of(&away, content, 1.0); - assert!(!seen.zoomed && seen.full_screen && seen.minimized); - // nothing reported but the content (Wayland): the size is there all the same - assert_eq!(Seen::of(&ViewportInfo::default(), content, 1.0).size, Some([650.0, 400.0])); - // a scale or a size that is none - assert_eq!(Seen::of(&info, content, f32::NAN).size, Some([650.0, 400.0])); - assert_eq!(Seen::of(&info, Rect::NOTHING, 1.0).size, None); - assert_eq!(Seen::of(&info, Rect::from_min_size(pos2(0.0, 0.0), vec2(0.0, 0.0)), 1.0).size, None); - } - - /// What the settings hold is not trusted as it is: a size is brought within what a window can - /// be, and anything that is no window reads as nothing remembered, without an error (which - /// would have the whole settings file taken for damaged). - #[test] - fn what_was_remembered_is_checked_and_never_costs_the_other_settings() { - #[derive(Deserialize, Debug, PartialEq)] - struct Settings { - #[serde(default, deserialize_with = "WindowPlace::lenient")] - window: Option, - other: u8, - } - let read = |window: &str| serde_json::from_str::(&format!(r#"{{"window":{window},"other":7}}"#)).map(|s| s.window); - assert_eq!(read(r#"{"size":[1300.0,800.0],"zoomed":true}"#).unwrap(), Some(place([1300.0, 800.0], true))); - assert_eq!(read(r#"{"size":[1300,800]}"#).unwrap(), Some(place([1300.0, 800.0], false))); - // (a place on the desktop, as a version that remembered one would write it, is passed over) - assert_eq!(read(r#"{"size":[1300,800],"position":[40,60],"zoomed":false}"#).unwrap(), Some(place([1300.0, 800.0], false))); - assert_eq!(read(r#"{"size":[200,100]}"#).unwrap().map(|p| p.size), Some(MIN_SIZE), "too small to be the app's window"); - for odd in [ - "null", - "{}", - r#""x""#, - "5", - "[]", - "true", - r#"{"size":[1,2,3]}"#, - r#"{"size":[1]}"#, - r#"{"size":"big"}"#, - r#"{"size":[null,800]}"#, - r#"{"size":[1300,800],"zoomed":1}"#, - r#"{"size":[0,0]}"#, - r#"{"size":[-5,800]}"#, - r#"{"size":[1e39,800]}"#, - r#"{"size":[99999,800]}"#, - ] { - assert_eq!(read(odd).unwrap(), None, "{odd}"); - } - assert_eq!(serde_json::from_str::(r#"{"other":7}"#).unwrap(), Settings { window: None, other: 7 }, "settings from before"); - // and what is written is read back - let written = serde_json::to_string(&place([1300.5, 800.25], true)).unwrap(); - assert_eq!(read(&written).unwrap(), Some(place([1300.5, 800.25], true))); - for size in [[f32::NAN, 800.0], [f32::INFINITY, 800.0], [0.5, 800.0]] { - assert_eq!(place(size, false).checked(), None, "{size:?}"); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/window_place/tests.rs b/crates/ui-egui/src/window_place/tests.rs new file mode 100644 index 000000000..80d463f19 --- /dev/null +++ b/crates/ui-egui/src/window_place/tests.rs @@ -0,0 +1,594 @@ +use super::*; + +const SCREEN: [f32; 2] = [2560.0, 1440.0]; + +fn window(size: [f32; 2]) -> Seen { + Seen { size: Some(size), screen: Some(SCREEN), ..Default::default() } +} + +fn place(size: [f32; 2], zoomed: bool) -> WindowPlace { + WindowPlace { size, zoomed } +} + +/// Given a window that is resized and comes to rest, when it is watched, then its new size +/// is remembered once it has rested for a moment, and none of the sizes it passed through. +#[test] +fn a_window_is_remembered_at_the_size_it_comes_to_rest_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let mut remembered = None; + for (i, width) in [1000.0, 1100.0, 1200.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), i as f64 * 0.1); + assert_eq!(remembered, None, "passing through {width}"); + assert!(watch.waits(i as f64 * 0.1).is_some(), "and the app looks again"); + } + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.5); + assert_eq!(remembered, None, "not yet at rest"); + // (it has been this size since 0.3: the app looks again just after 0.8) + let wait = watch.waits(0.5).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - 0.35).abs() < 1e-6, "{wait}"); + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.9); + assert_eq!(remembered, Some(place([1300.0, 800.0], false))); + assert!(watch.waits(0.9).is_none()); + // as it is: nothing to wait for + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 5.0), remembered); + assert!(watch.waits(5.0).is_none()); + } +} + +/// Given a window at rest that is zoomed (the top bar double-clicked), when it is watched +/// where the window system says so, then it is remembered as zoomed at once, with the size +/// it had before, though it grew through other sizes on the way; un-zoomed, it is no longer +/// zoomed at once and keeps that size. +#[test] +fn a_zoomed_window_keeps_the_size_it_goes_back_to() { + let mut watch = WindowWatch::told_live(true); + let rest = place([1300.0, 800.0], false); + let mut remembered = Some(rest); + for (i, width) in [1600.0, 2000.0, 2400.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 1300.0]), 10.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest)); + } + let zoomed = Seen { zoomed: true, ..window([2560.0, 1412.0]) }; + remembered = watch.note(remembered, &zoomed, 10.2); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &zoomed, 30.0), Some(place([1300.0, 800.0], true)), "however long it stays zoomed"); + assert!(watch.waits(30.0).is_none()); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 31.0); + assert_eq!(remembered, Some(rest), "no longer zoomed, and the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 31.2), Some(rest)); + // zoomed before anything was remembered: it opens zoomed, and goes back to the usual size + assert_eq!(WindowWatch::told_live(true).note(None, &zoomed, 0.0), Some(place(DEFAULT_SIZE, true))); + // the system's word is the answer there: a window as large as its screen is a window + assert_eq!(WindowWatch::told_live(true).note(Some(rest), &window([2560.0, 1335.0]), 0.0).map(|p| p.zoomed), Some(false)); +} + +/// Given macOS, where the window system says whether the window is zoomed only as it opens +/// and when the app zooms it itself: when a window that opened zoomed is resized by its edge, +/// then it is a window again, remembered at the size it was given, though the system's word +/// still says zoomed; zoomed once more, it is zoomed. +#[test] +fn a_zoomed_window_resized_by_its_edge_is_a_window_again_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1335.0]) }; + let mut remembered = watch.note(Some(rest), &zoomed, 0.0); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.0).is_some(), "the size it is zoomed at is still to be seen at rest"); + remembered = watch.note(remembered, &zoomed, 0.6); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.6).is_none()); + assert_eq!(watch.note(remembered, &zoomed, 60.0), Some(rest), "however long it stays zoomed"); + // (a size goes through the screen's pixels and back: a point off is the same size) + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([2559.0, 1336.0]) }, 60.5), Some(rest)); + // its right edge dragged in a little, far less than a Dock is wide; the word is unchanged + let dragged = Seen { zoomed: true, ..window([2500.0, 1335.0]) }; + remembered = watch.note(remembered, &dragged, 61.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], false)), "not zoomed any more"); + remembered = watch.note(remembered, &dragged, 61.6); + assert_eq!(remembered, Some(place([2500.0, 1335.0], false)), "and at the size it was given"); + assert!(watch.waits(61.6).is_none()); + // zoomed again from the Window menu: back at the size it is zoomed at + assert_eq!(watch.note(remembered, &zoomed, 70.0), Some(place([2500.0, 1335.0], true))); +} + +/// Given macOS, when the app zooms the window itself (the top bar double-clicked), then the +/// system says so at once and the window is zoomed through all the sizes it grows through, +/// with the size it had, wherever the Dock is; un-zoomed the same way, it is a window again +/// at that size. +#[test] +fn a_window_the_app_zooms_is_zoomed_at_once_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], false); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + // (a Dock at the left: zoomed, the window is narrower than its screen) + for (i, width) in [1300.0, 1700.0, 2100.0, 2410.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 1415.0]) }, 1.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1300.0, 800.0], true)), "growing through {width}"); + } + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2410.0, 1415.0]) }, 2.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert!(watch.waits(2.0).is_none()); + for (i, width) in [2100.0, 1700.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), 3.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest), "shrinking through {width}"); + } + assert!(watch.waits(3.1).is_none(), "back at the size it had: nothing to wait for"); +} + +/// Given macOS and a window zoomed from the Window menu (the system's word stays "not +/// zoomed"), when it is seen to come to fill its screen from side to side, then it is zoomed, +/// with the size it had, and zoomed back it is a window at that size again. A window that +/// only grows large is not zoomed, on any screen. +#[test] +fn a_window_that_comes_to_fill_its_screen_is_zoomed_where_the_system_does_not_say() { + let rest = place([1300.0, 800.0], false); + // a window of 1300 × 800 on `screen` that is next seen at `size` + let zoomed_on = |screen: [f32; 2], size: [f32; 2]| { + let mut watch = WindowWatch::told_live(false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + watch.note(Some(rest), &seen([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &seen(size), 0.2).map(|p| p.zoomed) + }; + // under the menu bar, with the Dock hidden, small, or as large as it gets + for (screen, size) in [ + ([2560.0, 1440.0], [2560.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1335.0]), + ([2560.0, 1440.0], [2559.0, 1335.0]), + ([1728.0, 1117.0], [1728.0, 1084.0]), + ([1512.0, 982.0], [1512.0, 794.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(true), "{size:?} on {screen:?}"); + } + // the usual size on a laptop's screen, windows sized by hand to nearly the screen, one + // that is a little narrower than it, and one as wide as it and far from as tall + for (screen, size) in [ + ([1728.0, 1117.0], DEFAULT_SIZE), + ([1710.0, 1107.0], DEFAULT_SIZE), + ([1512.0, 982.0], [1400.0, 820.0]), + ([1470.0, 956.0], [1340.0, 780.0]), + ([2560.0, 1440.0], [2430.0, 1270.0]), + ([2560.0, 1440.0], [2300.0, 1200.0]), + ([2560.0, 1440.0], [2550.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1190.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(false), "{size:?} on {screen:?}"); + } + let mut watch = WindowWatch::told_live(false); + let filling = window([2560.0, 1335.0]); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 0.9); + remembered = watch.note(remembered, &filling, 1.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &filling, 9.0), remembered, "and its own size is not written over"); + remembered = watch.note(remembered, &window([1900.0, 1000.0]), 10.0); + assert_eq!(remembered, Some(rest), "zoomed back: a window, at the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 10.2), Some(rest)); + assert!(watch.waits(10.2).is_none()); + // not told apart: zoomed from the Window menu beside a Dock at the side, it is remembered + // as a window of that size + let mut watch = WindowWatch::told_live(false); + let beside_a_dock = window([2410.0, 1415.0]); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &beside_a_dock, 0.3); + assert_eq!(watch.note(Some(rest), &beside_a_dock, 0.9), Some(place([2410.0, 1415.0], false))); + // without a screen's size to go by, the system's word is all there is + let no_screen = Seen { size: Some([2560.0, 1335.0]), screen: None, zoomed: true, ..Default::default() }; + assert_eq!(WindowWatch::told_live(false).note(Some(rest), &no_screen, 0.0), Some(place([1300.0, 800.0], true))); +} + +/// Given macOS and a window that was left as wide as its screen and a little shorter (its +/// lower edge dragged up after a zoom), when the app opens it at that size, then it is a +/// window of that size, launch after launch: filling the screen is no sign of a zoom unless +/// the window was seen to come to it. Dragged shorter still, it is remembered at that. +#[test] +fn a_window_that_opens_as_wide_as_its_screen_is_a_window_where_the_system_does_not_say() { + for (screen, size) in [([1728.0, 1117.0], [1728.0, 1054.0]), ([1512.0, 982.0], [1512.0, 760.0]), ([3440.0, 1440.0], [3440.0, 1200.0])] { + let left = place(size, false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + for frame in 0..5 { + assert_eq!(watch.note(Some(left), &seen(size), f64::from(frame) * 0.3), Some(left), "{size:?} on {screen:?}"); + } + let shorter = [size[0], size[1] - 20.0]; + watch.note(Some(left), &seen(shorter), 2.0); + assert_eq!(watch.note(Some(left), &seen(shorter), 2.6), Some(place(shorter, false))); + } + // the same after a double-click on the top bar that changed nothing (the system's word was + // stale, and is now "not zoomed"): still the window it is + let mut watch = WindowWatch::told_live(false); + let screen = [1728.0, 1117.0]; + let left = place([1728.0, 1054.0], false); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 0.0); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 1.0); + // (taken for zoomed on the system's word, until that word is taken back) + let unsaid = Seen { size: Some(left.size), screen: Some(screen), zoomed: false, ..Default::default() }; + assert_eq!(watch.note(Some(left), &unsaid, 2.0), Some(left)); + assert_eq!(watch.note(Some(left), &unsaid, 9.0), Some(left)); +} + +/// Given macOS and a zoomed window, when it is zoomed back from the Window menu (or resized by +/// its edge) before it was ever seen at rest, then it is a window at once, and the size it +/// comes to rest at is the window's, not taken for the size it is zoomed at. +#[test] +fn a_window_zoomed_back_at_once_is_a_window_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + let mut remembered = watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + assert_eq!(remembered, Some(rest)); + for (i, width) in [2200.0, 1700.0, 1240.0, 1240.0, 1240.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 820.0]) }, 0.3 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1240.0, 820.0], false)), "{width}"); + } + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([1240.0, 820.0]) }, 60.0), Some(place([1240.0, 820.0], false))); + assert!(watch.waits(60.0).is_none()); +} + +/// Given macOS and a zoomed window, when its screen changes (the display unplugged, another +/// resolution) and it is fitted to the new one, then it is still zoomed and keeps the size +/// it goes back to. Moved to a larger screen that it does not fill, it is a window. +#[test] +fn a_zoomed_window_is_still_zoomed_on_another_screen_where_the_system_does_not_say() { + for said in [true, false] { + let rest = place([1240.0, 820.0], false); + let on = |screen: [f32; 2], size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), zoomed: said, ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &on([2560.0, 1440.0], [1240.0, 820.0]), 0.0); + if said { + // (the system's word as the window opened: a window; the app zooms it later) + remembered = watch.note(remembered, &Seen { zoomed: false, ..on([2560.0, 1440.0], [1240.0, 820.0]) }, 0.0); + } + for now in [1.0, 2.0] { + remembered = watch.note(remembered, &on([2560.0, 1440.0], [2560.0, 1415.0]), now); + } + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "said: {said}"); + for now in [10.0, 10.6, 60.0] { + remembered = watch.note(remembered, &on([1728.0, 1117.0], [1728.0, 1084.0]), now); + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "on the smaller screen at {now}, said: {said}"); + } + assert!(watch.waits(60.0).is_none()); + // dragged over to a screen it does not fill + remembered = watch.note(remembered, &on([3440.0, 1440.0], [1728.0, 1084.0]), 70.0); + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "said: {said}"); + } + // seen zoomed in one frame only, and fitted to another screen long after: it had rested + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + let fitted = Seen { size: Some([1728.0, 1084.0]), screen: Some([1728.0, 1117.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &fitted, 5.0), Some(rest)); +} + +/// Given a size the window has not rested at yet, when the window goes full screen or is +/// minimized, or reports no size, then nothing waits (the app is not woken again and again +/// for as long as that lasts), and back as a window its size is seen afresh. +#[test] +fn nothing_waits_while_the_window_is_full_screen_or_minimized() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in + [Seen { full_screen: true, ..window(SCREEN) }, Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { size: None, ..window(SCREEN) }] + { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert!(watch.waits(1.0).is_some()); + assert_eq!(watch.note(Some(rest), &away, 1.1), Some(rest)); + assert!(watch.waits(1.1).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &away, 900.0), Some(rest)); + assert!(watch.waits(900.0).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &window([1500.0, 900.0]), 901.0), Some(rest)); + let mut remembered = Some(rest); + for now in [902.2, 902.8] { + remembered = watch.note(remembered, &window([1500.0, 900.0]), now); + } + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?}"); + assert!(watch.waits(902.8).is_none()); + } + } + // (macOS: the same of a zoomed window's size that was still to be seen at rest) + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, 0.0); + assert!(watch.waits(0.0).is_some()); + watch.note(Some(rest), &away, 0.1); + assert!(watch.waits(0.1).is_none(), "{away:?}"); + } +} + +/// Given a zoomed window that was full screen, when it leaves full screen, then for a moment +/// it is not looked at (the system animates the way back, at sizes that are neither the +/// window's nor the zoomed one), the app looks again once that is over, and the window is +/// zoomed with the size it goes back to, as before. +#[test] +fn a_window_that_has_just_left_full_screen_is_not_looked_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for now in [0.0, 1.0] { + assert_eq!(watch.note(Some(rest), &zoomed, now), Some(rest)); + } + assert_eq!(watch.note(Some(rest), &Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, 5.0), Some(rest)); + // on the way back the system no longer says "full screen", nor "zoomed" where its word is live + for now in [9.0, 9.3, 9.6, 9.9] { + assert_eq!(watch.note(Some(rest), &Seen { zoomed: !live, ..window(SCREEN) }, now), Some(rest), "at {now}"); + let wait = watch.waits(now).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - (10.05 - now)).abs() < 1e-6, "at {now}: looks again after the moment, {wait}"); + } + assert_eq!(watch.note(Some(rest), &zoomed, 10.1), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 11.0), Some(rest)); + assert!(watch.waits(11.0).is_none()); + } +} + +/// Given macOS and a window dragged by its corner, when it reaches the screen's width and is +/// then dragged on, taller and little by little narrower again, and left narrower than the +/// screen, then it is a window at the size it was left at: a zoom only grows on its way, so +/// what gets narrower again is somebody's hand. +#[test] +fn a_window_dragged_wide_and_narrower_again_is_a_window_where_the_system_does_not_say() { + for per_frame in [1.0, 1.4] { + let screen = [1728.0, 1117.0]; + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let rest = place([1600.0, 950.0], false); + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &seen(rest.size), 0.0); + let mut size = [1728.0, 950.0]; + let mut now = 1.0; + for _ in 0..40 { + remembered = watch.note(remembered, &seen(size), now); + size = [size[0] - per_frame, size[1] + 3.0]; + now += 1.0 / 60.0; + } + for _ in 0..3 { + remembered = watch.note(remembered, &seen(size), now); + now += 0.3; + } + assert_eq!(remembered, Some(place(size, false)), "{per_frame} pt narrower a frame"); + } + // a zoomed window whose edge is dragged in slowly: a window once it is no longer the size it was zoomed at + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let mut remembered = Some(rest); + for frame in 1..=10 { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2560.0 - frame as f32, 1335.0]) }, 2.0 + f64::from(frame) / 60.0); + } + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "ten points narrower, a point at a time"); + // and one whose lower edge is dragged up a little: it still fills its screen, and is a window + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let shorter = Seen { zoomed: true, ..window([2560.0, 1385.0]) }; + watch.note(Some(rest), &shorter, 2.0); + assert_eq!(watch.note(Some(rest), &shorter, 2.6), Some(place([2560.0, 1385.0], false))); +} + +/// Given macOS and a window that opened zoomed, when its edge is dragged in before it was +/// seen at rest (one side only gets smaller), or it is carried to a larger screen in that +/// moment, then it is a window; and a frame without the screen's size costs a zoomed window +/// nothing. +#[test] +fn a_zoomed_window_changed_before_it_rested_is_a_window_where_the_system_does_not_say() { + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for at in [0.1, 0.3] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let dragged = Seen { zoomed: true, ..window([2000.0, 1415.0]) }; + assert_eq!(watch.note(Some(rest), &dragged, at), Some(place([1240.0, 820.0], false)), "dragged at {at}"); + assert_eq!(watch.note(Some(rest), &dragged, at + 0.6), Some(place([2000.0, 1415.0], false))); + } + // carried to a larger screen, as large as it is, a moment after it opened: no frame came in between + for at in [0.2, 0.52, 0.9] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let carried = Seen { size: Some([2560.0, 1415.0]), screen: Some([5120.0, 2880.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &carried, at).map(|p| p.zoomed), Some(false), "carried at {at}"); + } + // the screen's size missing for a frame + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &zoomed, now); + } + assert_eq!(watch.note(Some(rest), &Seen { screen: None, ..zoomed }, 2.0), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 2.1), Some(rest)); +} + +/// Given macOS and a window that opened zoomed and was then resized by hand (the system's +/// word still says zoomed), when it goes full screen and back, or reports no size for a +/// frame, then it is still the window it was: what was made out of it is not let go of for +/// the system's old word. +#[test] +fn a_window_made_out_is_not_taken_for_zoomed_again_on_the_systems_old_word() { + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let dragged = Seen { zoomed: true, ..window([1500.0, 900.0]) }; + let mut remembered = watch.note(Some(rest), &dragged, 2.0); + remembered = watch.note(remembered, &dragged, 2.6); + assert_eq!(remembered, Some(place([1500.0, 900.0], false))); + remembered = watch.note(remembered, &away, 5.0); + for now in [8.0, 8.6, 9.2, 30.0] { + remembered = watch.note(remembered, &dragged, now); + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?} at {now}"); + } + } +} + +/// Given a platform where the system says whether the window is maximized, when a window +/// grows until it fills its screen, then it is a window all the same: only the system's +/// word counts there. And the watch the app makes goes by that word everywhere but on macOS. +#[test] +fn where_the_system_says_only_its_word_counts() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + let mut remembered = Some(rest); + for (i, size) in [[1300.0, 800.0], [2000.0, 1100.0], [2560.0, 1335.0], [2560.0, 1335.0], [2560.0, 1335.0]].into_iter().enumerate() { + remembered = watch.note(remembered, &window(size), i as f64 * 0.4); + assert_eq!(remembered.map(|p| p.zoomed), Some(false)); + } + assert_eq!(remembered, Some(place([2560.0, 1335.0], false))); + // the app's own watch: opened zoomed and then seen at a hand size, the system's word unchanged + let mut watch = WindowWatch::default(); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let zoomed = watch.note(Some(rest), &Seen { zoomed: true, ..window([1500.0, 900.0]) }, 2.0).map(|p| p.zoomed); + assert_eq!(zoomed, Some(!cfg!(target_os = "macos")), "macOS goes by the size, the others by the word"); +} + +/// Given a window that left full screen a moment ago, when it is minimized or goes full +/// screen again, then nothing waits; and a window that covers its screen altogether (full +/// screen, though nothing says so) is not remembered as a size, however long it stays. +#[test] +fn nothing_waits_when_a_window_just_back_from_full_screen_goes_away_again() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in [Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { full_screen: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &Seen { full_screen: true, ..window(SCREEN) }, 1.0); + watch.note(Some(rest), &window([1300.0, 800.0]), 2.0); + assert!(watch.waits(2.0).is_some(), "the moment after full screen"); + for now in [2.1, 2.7, 60.0] { + assert_eq!(watch.note(Some(rest), &away, now), Some(rest)); + assert!(watch.waits(now).is_none(), "{away:?} at {now}"); + } + } + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + for now in [1.0, 1.6, 2.2, 60.0] { + // (where the system does not say, a window that comes to cover its screen is zoomed) + assert_eq!(watch.note(Some(rest), &window(SCREEN), now).map(|p| p.size), Some(rest.size), "covering its screen at {now}"); + } + assert!(watch.waits(60.0).is_none()); + } +} + +/// Given a window smaller than the app's smallest (a window manager that does not keep to +/// it, a script), when it rests, then the smallest size is remembered, once: what is +/// remembered and what is seen agree from then on, and nothing waits. +#[test] +fn a_window_smaller_than_the_smallest_is_remembered_once() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let small = window([640.0, 700.0]); + let mut remembered = watch.note(Some(place([1300.0, 800.0], false)), &small, 0.0); + remembered = watch.note(remembered.and_then(WindowPlace::checked), &small, 0.6); + assert_eq!(remembered, Some(place([MIN_SIZE[0], 700.0], false))); + for frame in 0..20 { + let now = 1.0 + f64::from(frame) * 0.1; + assert_eq!(watch.note(remembered.and_then(WindowPlace::checked), &small, now), remembered); + assert!(watch.waits(now).is_none(), "frame {frame}"); + } + } +} + +/// Full screen and a minimized window are not sizes to open at: nothing is remembered of +/// them. Nor of a window that reports no size. +#[test] +fn full_screen_and_minimized_windows_are_not_remembered() { + let rest = place([1300.0, 800.0], false); + for seen in [ + Seen { full_screen: true, ..window(SCREEN) }, + Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, + Seen { minimized: true, ..window([160.0, 28.0]) }, + Seen { size: None, ..window([0.0, 0.0]) }, + ] { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + assert_eq!(watch.note(Some(rest), &seen, 0.0), Some(rest), "{seen:?}"); + assert_eq!(watch.note(Some(rest), &seen, 5.0), Some(rest), "{seen:?}"); + assert_eq!(watch.at_exit(Some(rest), 6.0), Some(rest)); + } + } +} + +/// Given a window that was resized, when the app quits before the size has rested its +/// moment, then the size is remembered if the window had been let go of, and not if it was +/// still changing with every frame (a zoom the system was animating). +#[test] +fn a_size_the_window_rested_at_is_remembered_when_the_app_quits() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert_eq!(watch.at_exit(Some(rest), 1.3), Some(place([1500.0, 900.0], false))); + assert_eq!(watch.at_exit(Some(rest), 9.0), Some(rest), "said once"); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.00); + watch.note(Some(rest), &window([1700.0, 1000.0]), 1.02); + assert_eq!(watch.at_exit(Some(rest), 1.04), Some(rest), "a size it was passing through"); + assert_eq!(WindowWatch::told_live(true).at_exit(None, 3.0), None); +} + +/// What egui holds of the window is read in the system's points: a size every platform +/// reports (the rectangle the app draws in), brought back from the interface's own scale. +#[test] +fn the_window_is_seen_in_the_systems_points() { + use egui::{Rect, ViewportInfo, pos2, vec2}; + let info = ViewportInfo { monitor_size: Some(vec2(1280.0, 720.0)), maximized: Some(true), fullscreen: Some(false), ..Default::default() }; + let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(650.0, 400.0)); + // (the interface at twice its size: egui's points are half the system's) + let seen = Seen::of(&info, content, 2.0); + assert_eq!(seen, Seen { size: Some([1300.0, 800.0]), screen: Some([2560.0, 1440.0]), zoomed: true, full_screen: false, minimized: false }); + let away = ViewportInfo { maximized: Some(false), fullscreen: Some(true), minimized: Some(true), ..Default::default() }; + let seen = Seen::of(&away, content, 1.0); + assert!(!seen.zoomed && seen.full_screen && seen.minimized); + // nothing reported but the content (Wayland): the size is there all the same + assert_eq!(Seen::of(&ViewportInfo::default(), content, 1.0).size, Some([650.0, 400.0])); + // a scale or a size that is none + assert_eq!(Seen::of(&info, content, f32::NAN).size, Some([650.0, 400.0])); + assert_eq!(Seen::of(&info, Rect::NOTHING, 1.0).size, None); + assert_eq!(Seen::of(&info, Rect::from_min_size(pos2(0.0, 0.0), vec2(0.0, 0.0)), 1.0).size, None); +} + +/// What the settings hold is not trusted as it is: a size is brought within what a window can +/// be, and anything that is no window reads as nothing remembered, without an error (which +/// would have the whole settings file taken for damaged). +#[test] +fn what_was_remembered_is_checked_and_never_costs_the_other_settings() { + #[derive(Deserialize, Debug, PartialEq)] + struct Settings { + #[serde(default, deserialize_with = "WindowPlace::lenient")] + window: Option, + other: u8, + } + let read = |window: &str| serde_json::from_str::(&format!(r#"{{"window":{window},"other":7}}"#)).map(|s| s.window); + assert_eq!(read(r#"{"size":[1300.0,800.0],"zoomed":true}"#).unwrap(), Some(place([1300.0, 800.0], true))); + assert_eq!(read(r#"{"size":[1300,800]}"#).unwrap(), Some(place([1300.0, 800.0], false))); + // (a place on the desktop, as a version that remembered one would write it, is passed over) + assert_eq!(read(r#"{"size":[1300,800],"position":[40,60],"zoomed":false}"#).unwrap(), Some(place([1300.0, 800.0], false))); + assert_eq!(read(r#"{"size":[200,100]}"#).unwrap().map(|p| p.size), Some(MIN_SIZE), "too small to be the app's window"); + for odd in [ + "null", + "{}", + r#""x""#, + "5", + "[]", + "true", + r#"{"size":[1,2,3]}"#, + r#"{"size":[1]}"#, + r#"{"size":"big"}"#, + r#"{"size":[null,800]}"#, + r#"{"size":[1300,800],"zoomed":1}"#, + r#"{"size":[0,0]}"#, + r#"{"size":[-5,800]}"#, + r#"{"size":[1e39,800]}"#, + r#"{"size":[99999,800]}"#, + ] { + assert_eq!(read(odd).unwrap(), None, "{odd}"); + } + assert_eq!(serde_json::from_str::(r#"{"other":7}"#).unwrap(), Settings { window: None, other: 7 }, "settings from before"); + // and what is written is read back + let written = serde_json::to_string(&place([1300.5, 800.25], true)).unwrap(); + assert_eq!(read(&written).unwrap(), Some(place([1300.5, 800.25], true))); + for size in [[f32::NAN, 800.0], [f32::INFINITY, 800.0], [0.5, 800.0]] { + assert_eq!(place(size, false).checked(), None, "{size:?}"); + } +} diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index aa80e8301..54ad8f186 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,4 +1,5 @@ //! Real library/tool panel moves preserve the selected tool and saved arrangement. +use craft_ui::docking::{DockArea, Hit, Node, Side}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel}; @@ -41,13 +42,22 @@ fn settle_photo(h: &mut Harness<'_, LightcraftApp>) { let started = Instant::now(); let mut quiet = 0; while quiet < 4 { - h.run_steps(2); - if h.state().renderer.in_flight() == 0 && h.state().loupe_shown.is_some_and(|(_, source)| source == "render") { + if h.state().renderer.in_flight_without_prefetch() > 0 { + h.state_mut().renderer.wait_for_worker(Duration::from_millis(250)); + } + h.run_steps(1); + // Neighbouring-photo prefetch does not contribute pixels to this capture. + if h.state().renderer.in_flight_without_prefetch() == 0 && h.state().loupe_shown.is_some_and(|(_, source)| source == "render") { quiet += 1; } else { quiet = 0; } - assert!(started.elapsed() < Duration::from_secs(30), "the synthetic photo and panel thumbnails must finish rendering before capture"); + assert!( + started.elapsed() < Duration::from_secs(120), + "the synthetic photo and panel thumbnails must finish rendering before capture: loupe {:?}, pending {:?}", + h.state().loupe_shown, + h.state().renderer.pending_slots() + ); std::thread::sleep(Duration::from_millis(5)); } } @@ -63,7 +73,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { h.run_steps(3); h.get_by_label("Float panel").click(); h.run_steps(5); - let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect; + let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect; h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary); h.run_steps(3); h.get_by_label("Close panel").click(); @@ -71,7 +81,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { assert_eq!(h.state().ui.right, RightPanel::None); h.state_mut().ui.right = RightPanel::Crop; h.run_steps(4); - assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect); + assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, rect); let saved = serde_json::to_string(&h.state().ui).unwrap(); let expected = serde_json::to_value(&h.state().ui.docking).unwrap(); let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0); @@ -86,6 +96,61 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { assert_eq!(reloaded.state().ui.right, RightPanel::Crop); } +#[test] +fn dragging_a_group_by_its_bar_moves_the_whole_group() { + let mut h = harness(None, 1400.0, 1.0); + h.state_mut().ui.presets = true; + h.run_steps(4); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"presets","target":"tools","zone":"center"})).unwrap(); + h.run_steps(6); + let bar = DockArea::::hit_rects(&h.ctx, egui::Id::unique("lightcraft-docking")) + .into_iter() + .find_map(|(hit, rect)| matches!(hit, Hit::DragBar(Panel::Tools | Panel::Presets)).then_some(rect)) + .expect("the Tools and Presets group draws its drag bar"); + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center(); + drag(&mut h, bar.center(), target); + let mut library = start_dock(&h).into_iter().flatten().find(|panels| panels.contains(&Panel::Library)).unwrap(); + library.sort_by_key(|panel| panel.id()); + assert_eq!(library, [Panel::Library, Panel::Presets, Panel::Tools], "both panels of the dragged group joined Library"); + assert_eq!(h.state().ui.right, RightPanel::Edit); +} + +/// The start dock's columns from the window edge inwards, each as its groups' panels. +fn start_dock(h: &Harness<'_, LightcraftApp>) -> Vec>> { + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + let mut nodes: Vec<_> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: Side::Start, columns } => { + return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect(); + } + _ => {} + } + } + Vec::new() +} + +/// In a dock column, a group's top third stacks above it, its photo-side third adds a column +/// there and its middle joins its tabs. +#[test] +fn a_tab_dropped_on_a_dock_group_stacks_adds_a_column_or_joins_by_where_it_lands() { + let cases: [(fn(egui::Rect) -> egui::Pos2, Vec>>); 3] = [ + (|r| egui::pos2(r.center().x, r.top() + r.height() / 6.0), vec![vec![vec![Panel::Tools], vec![Panel::Library]]]), + (|r| egui::pos2(r.right() - r.width() / 6.0, r.center().y), vec![vec![vec![Panel::Library]], vec![vec![Panel::Tools]]]), + (|r| r.center(), vec![vec![vec![Panel::Library, Panel::Tools]]]), + ]; + for (at, expected) in cases { + let mut h = harness(None, 1400.0, 1.0); + let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1; + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); + drag(&mut h, source, at(library)); + assert_eq!(start_dock(&h), expected, "dropped at {:?} of {library:?}", at(library)); + lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap(); + assert_eq!(h.state().ui.right, RightPanel::Edit); + } +} + #[test] fn invalid_docking_requests_preserve_layout_and_selected_tool() { let mut h = harness(None, 1400.0, 1.0); @@ -115,7 +180,7 @@ fn docking_renders_real_photo_panels_default_floating_and_redocked() { } h.run_steps(8); settle_photo(&mut h); - h.state().ui.docking.as_ref().unwrap().validate().unwrap(); + lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap(); if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { std::fs::create_dir_all(&directory).unwrap(); h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap(); diff --git a/docs/ai-masks.md b/docs/ai-masks.md index 84e082163..845dc0e23 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -115,7 +115,7 @@ The model is fetched from an ordered list of mirrors: base URLs where `/mo 2. the file `models/sam3-mirrors.txt` in LightCraft's settings folder (one URL per line, `#` comments) — `~/Library/Application Support/LightCraft/` (macOS), `%APPDATA%\LightCraft\` (Windows), `~/.config/lightcraft/` (Linux); -3. the built-in list, `DEFAULT_MIRRORS` in `crates/segment/src/fetch/mod.rs`. +3. the built-in list, `DEFAULT_MIRRORS` in `crates/segment/src/fetch.rs`. > **Maintainers:** the built-in list is **empty** until LightCraft's own CDN locations exist > (see the `TODO(maintainer)` there): add them in order of preference, host the three files diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 86deac24d..0c9281dfc 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -66,6 +66,6 @@ guard.detail(&file_name); ## Testing Assert the row (`session.activity.list()`: kind, label, total, `cancellable`), that `activity.cancel(id)` stops the -job, and that the row is gone once the job ends. `crates/ui-egui/src/tests_activity.rs` has the helpers, and the +job, and that the row is gone once the job ends. `crates/ui-egui/src/tests/activity.rs` has the helpers, and the job-specific tests show the pattern (`tests_sync.rs`, `export_task.rs`, `tests_denoise.rs`…). A row only appears on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:` first. diff --git a/docs/contributors.md b/docs/contributors.md index e2ef889e5..78e234dfa 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -23,7 +23,7 @@ this page is the local copy of the decision. ## In LightCraft -- The About dialog (`crates/ui-egui/src/panels/dialogs.rs`, Help ▸ About LightCraft) has the tabs +- The About dialog (`crates/ui-egui/src/dialogs.rs`, Help ▸ About LightCraft) has the tabs **About · Contributors · Models**; the control channel can switch them with `ui.clickWidget {"id": "button:aboutTab-contributors"}` (`-about`, `-models`), and the list view with `button:creditsGrabBag` / `button:creditsTable`. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a8033aa5..36294c787 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -10,7 +10,9 @@ ``` Requests are answered on the UI thread between frames (timeout 60 s). A request is given up -if the app has not started it and draws no frame for 5 s. The reply is then `{"ok": false, "error": "the app drew no frame for 5 s, …"}`, +if the app has not started it and draws no frame for 5 s. Synthetic pointer input is fed one event +per frame; the same 5 s limit is a delivery stall, restarted whenever the app consumes a batch, so a +many-step drag on a slow frame is not cut off while an app that stops drawing still fails. The reply is then `{"ok": false, "error": "the app drew no frame for 5 s, …"}`, and the app never runs that request later, so a retry doesn't do it twice. This happens on Wayland when the window is on a hidden workspace or minimized: the compositor sends no frame callbacks, so the app's loop is paused. Keep the window on screen while you drive it, or use @@ -79,20 +81,20 @@ filmstrip is visible. | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | | `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | -| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | -| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | +| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks; `count` is capped at 16) / `{id, toX?, toY?, dx?, dy?, steps?}` (steps are capped at 1000) / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews). `fx`/`fy` are fractions of the registered rect and may lie outside `[0,1]`; the resulting point must still be inside egui's clipped interaction rect, and a non-finite fraction or resolved point is rejected before any input is queued. Geometry comes from the latest completed layout; if the widget moves before the first event or before a press, or its latest clipped rect no longer contains the point, the request fails with an error instead of clicking another widget or retargeting. A target below an ordinary modal dialog may succeed as a no-op or a backdrop click, because egui routes that input to the dialog; a target covered by a non-modal overlay fails. The blocking library-problem/launch-list window is explicit: its dim takes the modal layer, but a background target fails instead of reporting a no-op success, and only that window's own controls (`button:library…`) pass. Its creation frame is shielded and redrawn with empty input like a prompt's, so background text/pointer input cannot act while the new modal areas settle. A quit prompt or settings notice is a real egui modal scope with a foreground backdrop. Only its own buttons (`button:quit…`, `button:noticeOk`) accept a `ui.clickWidget`; background targets fail, and background keystrokes, text/clipboard input, wheel/zoom input and AccessKit actions are dropped before panels see them. The prompt itself takes Tab/Shift+Tab, Enter/Space on the focused button, and Escape/Enter for the safe default Cancel; AccessKit Focus/Click works when targeted at a prompt button. Its first creation frame (including a Tasks→Unsaved or notice→next-notice transition) is shielded and redrawn with empty input, so a press/release in that frame, or a held press from before it, cannot act underneath it; a synthetic drag already in progress is canceled. Once settled, raw `ui.click`/`ui.drag`/`ui.pointer` and backdrop clicks are still delivered to the prompt backdrop as no-ops, and the prompt's own button clicks work normally. The area/layer check cannot see a different widget covering the point in the same layer. A `ui.clickWidget` whose widget disappears before its release also fails, after canceling the pointer press without a click or drag (the same rules apply when a click spans several host frames) | +| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points (the same click and step caps apply) | | `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) | -| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press | +| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press; while a prompt is up only its Tab/Shift+Tab, Enter, Space and Escape are delivered, every other key is dropped | | `ui.text` | `{text}` | Text input | -| `ui.clipboard` | `{action: cut\|copy\|paste, text?}` | Cut, copy or paste in the focused text field, as the system does for ⌘X / ⌘C / ⌘V; `paste` pastes `text`, or the system clipboard without it. Refused when no text field has the focus (outside one, ⌘C / ⌘V are the commands `develop.copy` / `develop.paste`). In the desktop app cut and copy write the system clipboard. `ui.inspect` → `copied` is the text the UI last put on the clipboard | -| `ui.scroll` | `{dx, dy, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms | +| `ui.clipboard` | `{action: cut\|copy\|paste, text?}` | Cut, copy or paste in the focused text field, as the system does for ⌘X / ⌘C / ⌘V; `paste` pastes `text`, or the system clipboard without it. Refused when no text field has the focus (outside one, ⌘C / ⌘V are the commands `develop.copy` / `develop.paste`). In the desktop app cut and copy write the system clipboard. Refused while a prompt or the blocking library window owns input, including a window in its creation frame. A `paste` without `text` queues egui's `RequestPaste` viewport command for the host: egui 0.37 gives that command no completion/correlation id, and egui-winit emits a later `Event::Paste` only for non-empty text (or a `PasteImage` fallback; asynchronous clipboard reads finish later), so the reply confirms the request was queued in egui, not that the host dispatched it or that it was delivered; a barrier created after the request can drop that later event. `paste` with `text` is synthetic and is canceled like other injected input. `ui.inspect` → `copied` is the text the UI last put on the clipboard | +| `ui.scroll` | `{dx, dy, source?, unit?, phase?, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms. `source` is `unknown` (default), `wheel`, `trackpad` or `momentum`; `unit` is `point` (default), `line` or `page`; `phase` is `move` (default), `start`, `end` or `cancel`. Wheel/line events drive the grid/filmstrip one-third wheel step and the loupe's notch zoom; trackpad/momentum phases drive kinetic scrolling and rubber-band boundaries. Invalid values are refused and queue nothing | | `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` | | `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) | | `ui.resize` | `{width, height}` | Resize the window | | `ui.dropFiles` | `{paths: [..], hover?}` | Files dragged from the file manager. `hover: true` makes every following frame report `paths` as hovered over the window (the drop card appears, `ui.widgets` lists `dropOverlay`) until another `ui.dropFiles` replaces them; `{paths: [], hover: true}` ends the hover. Without `hover` the files are dropped: the hover ends and the next frame receives them as dropped files, exactly as an OS drop arrives (photos and folders open the import review, or import at once with Settings ▸ Import ▸ Import dropped files without review). An empty path stands for a file whose path the platform doesn't tell | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | -| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:`, `compare:renderFailed:`) | +| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. A `path` that did not exist when the request was admitted is written with create-new semantics and is never replaced if a file (including a newly imported original) appears there while the capture is queued; an existing ordinary file may be refreshed. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:`, `compare:renderFailed:`) | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | @@ -135,6 +137,12 @@ x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls `focus.pump {pace}` every frame to drive it. +With several photos selected, the Info panel's Show Metadata For is `view.infoSelectedPhotos {on?}` (no `on`, or +`on: null`, toggles; any other value that is not true or false is refused; replies `{on}`; +`settings.infoSelectedPhotos`): off (Target Photo) shows the active photo's values and saves edits to it alone, on (Selected Photos, the default) +shows the values the selected photos share, leaves each text field and GPS where they differ empty with a Mixed hint, +and shows Mixed as the Copyright Status when the statuses differ. + The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: "charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. @@ -326,8 +334,9 @@ lightcraft-cli snapshot --library DIR --script tour.jsonl -o shot.png Headless screenshot dimensions must be finite and at least one logical point, with a finite, positive scale. Rounded output must be at least one pixel per edge, at most 16,384 pixels -per edge and 64 million pixels in total. `snapshot` and `ui.resize` reject requests outside -these limits before layout or rasterization; supplied resize dimensions must be numbers. +per edge and 33,554,432 pixels in total (128 MiB of RGBA, the per-image screenshot +budget). `snapshot` and `ui.resize` reject requests outside these limits before layout or +rasterization; supplied resize dimensions must be numbers. An interface zoom (egui's zoom factor; the keyboard never changes it — ⌘= / ⌘− / ⌘0 zoom the photo, issue #566) changes text and control sizes while preserving the snapshot viewport's requested physical pixel dimensions. Later `ui.resize` requests use current egui points; validation @@ -364,11 +373,11 @@ the pending name. `ui.dialog.confirm` starts the same import into the selected a ## Panel docking -Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. +Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences as a craft-ui docking workspace (`docking`: `version`, `layout`, `hidden`, `saved`, `locked`); settings written before workspaces had a `version` load as version 1. These operations do not edit document contents. Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; `Center`, `Left`, `Right`, `Top` and `Bottom` also parse, while `tab`, `above` and `below` are lowercase only). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..72b516f9e --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1552 @@ +# LightCraft: egui 0.37 migration plan + +Current reconciliation (2026-10-25): this tree incorporates main `31ec62cd0387abecd8f2a2da034aee591447897d`. Managed CI `egui037-light-main31-ci-20261025-0829` passed all eight stages, including strict lint, HEIF and WASM checks: 5,086 passing test executions, zero failures and 42 ignored across 89 summaries. The prerequisite report separately identifies 149 tests that returned without exercising their checks (corpus 137, models 8, fonts 2, GPU 1, system 1); these are coverage gaps. Independent reconciliation review found no source blocker; its stale-status and pending-CI findings are addressed by this record. Native/browser GUI, platform, packaging/licence coverage and paired performance checks remain open. Human review and human merge are required; publication needs separate approval. + +Historical landing status before this reconciliation, 2026-10-25: the migration integrates main `38f8d2508700fa4938295e865d3fc21be07266ba` and uses egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc` and craft-ui `25da1314d443171b4adeaad2cd9454b273f35c6f`. Cargo.lock SHA-256 is `6336b4aa2ac9df57cb8feaafb097b30fa7228ef427f5e213fac1a2a7875e4181`; the independent craft-docpath pin remains selected in the lockfile. Release workflows use craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. + +Fresh static review verified preservation of the captured main and the final dependency graph. The final managed CI run `egui037-lightcraft-library-frame-ci-20261025-0551` passed all eight stages. Including its focused library tests, the log records 5,083 passing test executions, zero failures and 42 ignored across 142 summaries. The CI prerequisite report separately identifies 149 tests that returned without exercising their checks (corpus137, models8, fonts2, GPU1, system1); these are coverage gaps. Earlier passing counts below qualify their historical snapshots only. The icon-font attribution URL now identifies the selected egui source; font bytes are unchanged. Visible GUI, browser/platform runtime, native IME, final packages and paired performance remain separate acceptance gates. Human review and human merge are required. + +The review packet separately records inherited default-font notice coverage and Inter licence staging gaps. Nix font-pin drift is fixed as described below; the other inherited gaps are not established as fixed by the migration or by source-level notice routing checks. + +Current status note (2026-10-25): `flake.nix` now points at craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`, matching CI, the release workflow and the standalone packaging helpers, and `flake.lock` was regenerated by Nix 2.26.3 using `nix flake update craft-fonts` against the canonical GitHub revision. Only the craft-fonts node changed; nixpkgs stayed pinned. Linux x86_64 flake evaluation passed with `nix flake check --no-build --no-write-lock-file` (job `egui037-lightcraft-nix-eval-20261025-0495`). No Nix package build, executed formatting derivation, or other architecture is claimed. The inherited gaps above (egui default-font licence coverage for Hack, Ubuntu-Light and NotoEmoji-Regular; the Inter OFL packaging omission) are not migration-introduced, and this landing updates the craft-fonts notices and attribution text only. + +Font notice and Inter staging follow-up (2026-10-25): the inherited gaps above are now addressed at +source level only. `NOTICE` reproduces the pinned upstream texts verbatim for Hack Regular (MIT and the +Bitstream Vera License), Ubuntu Light (Ubuntu Font Licence 1.0) and Noto Emoji Regular (SIL OFL 1.1), +next to the existing egui-icons/emoji-icon-font block; `assets/ATTRIBUTION.md` gains rows for +`epaint_default_fonts::HACK_REGULAR`, `UBUNTU_LIGHT` and `NOTO_EMOJI_REGULAR`; `packaging/env.sh` +`copy_font_licences` now always stages `assets/fonts/OFL-Inter.txt` as `OFL-Inter.txt` before the optional +craft-fonts OFLs (Linux, macOS app and CLI, FreeBSD and web use it), and the Flatpak from-source manifest +installs it directly. The release Flatpak bundle repackages the Linux tarball and so inherits its documents; +Windows and Nix already staged Inter. Managed staging checks verified the three upstream licence texts +byte-for-byte in `NOTICE`, copied Inter with and without an optional craft-fonts input, and confirmed +that a missing Inter licence makes staging fail. These checks exercise the shared document-copy helper; +actual release artifacts (deb/rpm/tar, macOS app and CLI, FreeBSD, web zip, Flatpak, MSI/portable zip, Nix) +and their installed notice contents remain unverified. + +Library-check results now remain visible for the frame in which they arrive, so a zero-delay refresh cannot immediately replace a fresh missing-library answer. The scheduling fence uses the root UI frame number across all layout passes; focused regressions cover the single-pass and forced-multipass cases with isolated temporary directories. Fresh static review accepted the delivery-frame behavior. + +The original planning and earlier qualification record follows. Its pins, hashes, paths and passing counts are historical. + + +## Current implementation status — 2026-10-24 + +The implementation is now pinned to egui `173cc7375d35dc5fbc5b5d0994fdf787f547c5d1`, +winit `ae48c756a3b81125eb796c0cc6dccf60990d1181`, craft-libs +`c488caa9fbc2fb1a86e4674887e9b80b598ebbfa`, craft-fonts +`b076417b5880bd4154bd22f00695b1d28c6bea83` and craft-i18n +`e5bfe19b95d30f50f250985bdc3aeec47c05af09`. Cargo-generated lock SHA-256: +`2b6c3550997fd4962df7865ec038cc409604aafaaf2267fa416ad9d98df91da9`. +These are exact local reviewed revisions; public fetchability still requires the separately +approved foundation publication. Earlier planning and source-only validation statements below +are historical and are superseded by this status. + +The mandatory `cargo xtask ci` passed all eight stages (fmt, strict clippy, HEIF, tests, +parity, layers, assets and WASM). Its log reports 4,747 passing test invocations, zero failures +and 40 ignored tests. The CI prerequisite report separately lists 149 checks that returned +without exercising their checks: corpus 137, models 8, fonts 2, GPU 1 and system 1. These are +coverage gaps, not qualified behavior. Canonical source identities were resolved through local +mirrors; no public remote-fetch acceptance is claimed. + +Fresh review and regression coverage include capture-parent symlink retargeting: completion +re-resolves the lexical parent and refuses a changed destination before encoding or writing. +The two standalone packaging helpers now default to the same craft-fonts pin as release CI; +explicit font-directory overrides and existing checkouts retain their behavior. + +The current-main integration includes `4ae3089420cb57d4c839e51a735d4f48271cbf17` and +preserves its catalog hardening and other changes. Independent review caught a bad-chain +memoization error: a descendant visited before a broken ancestor could be detached unnecessarily. +The repair caches only proven valid chains and removes the offending parent edge, preserving +valid descendants. Regressions cover child-before-cycle, child-before-missing-parent and a valid +513-folder chain through actual catalog snapshot/reopen paths. All nine focused catalog repair +tests passed before the full eight-stage CI run on the merged source. The only formatting change +after the source review wraps one let-chain condition; the lockfile remains unchanged. + +Actual visible UI, native platform/IME/PRIMARY, browser behavior, final release-package notices +and paired performance acceptance remain open. This application branch has not been published. + +## Historical plan and review revisions + +Status: **Revision R3.2** — R3.1 plus the 2026-10-24 implementation-status amendments: +control-gesture delivery no longer re-checks the state snapshot after the gesture's own event has +applied (freshness is still checked before resolution and immediately before injection), every +dropdown now publishes a semantic accessible name, and the craft-libs reference moved to the local +`3832c47…` reconciliation base. The R3.1 concrete implementation notes for control-gesture +delivery, screenshot path identity, `ui.scroll` units/phases, the hidden-paint predicate and +disclosure `Expanded` state remain in force. R3 replaced +R2 (SHA-256 `385e46055494cc24b58e41ee08982c876eb423df314b0abffcdc28ac81cf9d24`) after the +independent review in `plan-reviews/lightcraft-v2.md`. Every review finding is resolved inline in the section +that owns it; §13 records each disposition, including the parts rejected. No production code, +manifest, lockfile, test, asset or build was changed and **no test, build, benchmark or GUI run was +performed for R3**: every source statement below is static inspection of the pinned trees. No +executed-validation, GPU-pass or browser-pass claim was made at that planning stage. + +No push, pull request, release, tag, comment or other publication is authorized. Every future push +or new pull request needs explicit human approval of the exact content and the validation results +first. + +This plan covers only `storytold/lightcraft` at +`23e181283cb448205d865e8cd288b56afe987986` (branch `codex/egui-037-plan-20261022` in the assigned +checkout). It does not migrate the egui fork, winit, craft-libs or a sibling app; those are separate +plans, separate review units and separate publication gates. + +The root `AGENTS.md`/`CLAUDE.md` and the nested `docs/user/AGENTS.md`/`CLAUDE.md` were read. This +plan changes no user documentation, asset, contributor metadata or build artefact; the only file it +adds is this plan. + +The historical diagnostics in +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/lightcraft.errors` and +`lightcraft.log` are clues from an earlier checkout. They are not current source, not a build of this +checkout and not acceptance evidence. They show 17 library errors and 26 library-test errors plus +243 warnings from the fork trial; the source audit below found additional semantic changes (panel +IDs, capture correlation, dropped-file replay) that a compiler diagnostic cannot show. + +## 1. Exact baseline and provenance + +| Item | Revision or value | +|---|---| +| LightCraft source baseline | `23e181283cb448205d865e8cd288b56afe987986` (`Release 0.13.0`) | +| LightCraft lockfile | `Cargo.lock`, format version 4 | +| LightCraft Rust baseline | workspace `rust-version = "1.90"`; `lightcraft-ui-egui` declares `1.95`; inspection toolchain `rustc 1.99.0` | +| Current egui family | `egui`, `epaint`, `ecolor`, `emath`, `eframe`, `egui-wgpu`, `egui-winit`, `egui_glow`, `epaint_default_fonts`, `egui_kittest` all `0.36.2` | +| Current winit | `0.30.13` from crates.io, checksum `a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d` | +| Current accessibility/test stack | `accesskit 0.24.1`, `accesskit_consumer` 0.35/0.36/0.38, `kittest 0.4.0`, `egui_kittest 0.36.2` | +| craft-libs reconciliation base | `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` | Current local shared-library tree and the base for the implemented port; it already carries PR 199's egui 0.37 API update, while the CL-2F capture helper remains uncommitted and is a publication gate. The exact reachable publish pin is still a central-publication gate | +| craft-libs historical inspection | `49190884278f16c9a9f5823d7698a8ffb5deb062`; PR 199 `8614e4a` + README `1764bca` | Earlier base and hunks; retained as history only. The port shape is already contained in the `3832c47…` base | +| Target upstream release | egui `0.37.0`, peeled commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`; the annotated tag object SHA is not the commit SHA | +| Comparison-only egui tree | `aiongg/egui` `craft/0.37` at `a4f9a17f64a6bae61350fb85804c9df0d9865916` (E1–E21) | +| Comparison-only winit tree | `aiongg/winit` `craft/0.30.13-lang` at `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; storytold base `ffae5198770d7730e718043f035a772b7dbc3b1b` | +| craft-libs stale trees | PR 199 head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` and audit merge `ec8840af8c5fda695cf195cdd05e5ae078415c07` | Evidence only, not mergeable trees: they omit current `craft-platform` and revert security/settings/docking/printer fixes | +| Language work | craft-libs issue/PR 198 is a separate proposal, not a merged dependency and not included here | + +**Pin policy for this consumer.** `a4f9a17f` (egui) and `8b35388b` (winit) are *comparison and +inspection* references only. LightCraft must pin the final reviewed **storytold** egui and winit +revisions by immutable full SHA **once the fork and winit series are published**. Those revisions do +not exist yet, so this plan states no SHA for them and does not guess one. Every stage that needs +them is explicitly blocked (§9, §10). LightCraft's production dependency is the complete reviewed +and published storytold egui **P8** revision: E1–E21 plus **P2b** (E4 regular/PRIMARY with bounded +asynchronous W15P transfer completion), **P7** held-key release pairing, **P7b/E21b** outgoing IME +surrounding text, and **P7c** (the selected #8763/#8748 fixes and justified performance fixes). Its +companion is the complete published storytold winit revision with **W15P** and the **W27** IME +lifecycle. No stock 0.37 or intermediate fork stage is a production dependency; stock 0.37 is only +the measurement control. A published revision missing any of these is not acceptable for the stages +named below. + +## 2. Dependency and entry-point inventory + +All manifests in the checkout were inspected: + +- root `Cargo.toml` (`[workspace]`, `[workspace.dependencies]`, `[workspace.lints]`, profiles); +- `apps/lightcraft/Cargo.toml`, `apps/lightcraft-cli/Cargo.toml`, + `apps/lightcraft-web/Cargo.toml`; +- all 29 `crates/*/Cargo.toml` and `xtask/Cargo.toml`. + +There is one workspace, one lockfile, no nested workspace, no Cargo `vendor/` directory and no +`[patch]` section today. `crates/raw/src/vendor/` is a set of in-tree raw-decoder modules (Sony, +Nikon, Canon, Fujifilm, …), not a Cargo vendor directory and not egui-related; it is preserved and +out of scope. There are no vendored egui/winit/egui-wgpu copies. `.cargo/config.toml` only defines +the `xtask` alias. `egui_extras` is declared in the workspace but unused and absent from the lock +graph; do not add it to production just to patch it. `egui_plot`, `egui_dock` and +`egui_inspection` are unused. `craft-ui` is the only shared UI dependency; `craft-docpath` is not +egui-related. + +| Target | egui entry point | Notes | +|---|---|---| +| `lightcraft` (native) | `apps/lightcraft/src/main.rs:60` `impl eframe::App`; `logic` calls `LightCraftApp::logic`, `ui` calls `LightCraftApp::ui` | eframe features `default_fonts`, `wgpu`, `glow`, `accesskit`, `wayland`, `x11` today; native menu, GPU probe/fallback, control server (`MAX_CONNECTIONS = 16`, `MAX_LINE = 4 MiB`) | +| `lightcraft-web` | `apps/lightcraft-web/src/web.rs:652` `impl eframe::App for WebApp`; `start` at `:726` | eframe `default_fonts`, `glow`; browser-loaded Chinese face; workers; OPFS/IndexedDB; `command()` → `LightcraftApp::run`, **no control server** | +| `lightcraft-cli` | `apps/lightcraft-cli/src/main.rs`: `snapshot` at `:767`, `render`, `mcp` | headless `Headless`/`HeadlessView`; no eframe | +| `lightcraft-ui-egui` | `crates/ui-egui/src/lib.rs:1077` `LightCraftApp::logic`, `:1441` `LightCraftApp::ui`; `headless.rs:133` `HeadlessView::run`, `headless.rs:311` `Headless::step` | the UI crate and its integration tests | +| `crates/ui-egui/tests` | `accessibility.rs`, `docking_workflows.rs`, `shutdown.rs` | kittest harnesses and AccessKit assertions | +| examples | `drag_latency`, `grid_bench`, `open_timeline`, `thumb_request_bench` | existing UI/perf harnesses | + +Platform-specific declarations that must stay coherent with the selected winit/egui: + +- `apps/lightcraft` has direct `winit = "0.30"` with `x11` only on + `cfg(all(unix, not(target_os = "macos")))`; +- `apps/lightcraft` has `rfd` only on non-wasm; `zbus` only on Linux; `muda`, `objc2`, + `objc2-app-kit`, `objc2-foundation` only on macOS; +- `apps/lightcraft-web` puts eframe/egui/wasm-bindgen/web-sys under `cfg(target_arch = "wasm32")`; +- `lightcraft-ui-egui` has `rust-version = "1.95"` and dev-dependency `egui_kittest` with + `wgpu` and `snapshot`. + +### 2.1 Feature policy (explicit, per root) + +egui 0.37 has **no default `default_fonts`** (`egui/Cargo.toml`: `default_fonts` off by default), +and 0.37 moved the monochrome emoji faces out of `default_fonts` into +`monochrome_emoji_fonts` (`epaint/src/text/font_definitions.rs:127-183` inserts +`NotoEmoji-Regular`/`emoji-icon-font` only under that cfg). eframe 0.37's default feature set +includes `clipboard`, and its `egui-winit` dependency is `default-features = false`, so an app that +turns eframe defaults off must add `clipboard` itself (`eframe/Cargo.toml:142-144`; +`egui-winit/Cargo.toml:44-46`, `clipboard.rs`). + +| Root | Features after migration | Why | +|---|---|---| +| workspace `egui` | `default-features = false`, `features = ["default_fonts", "monochrome_emoji_fonts"]` | every resolution root needs the bundled faces: CLI snapshot, headless harnesses and unit tests never see eframe's features. Monochrome emoji are needed because user keywords/filenames carry emoji (`tests_keywording.rs:558-583` uses `🙂`) and 0.36's `default_fonts` supplied them. The workspace feature is what supplies LightCraft roots; `egui_kittest`'s own dev-dependency enables them only for upstream kittest's tests and does not propagate | +| `lightcraft` (native) | eframe: `default_fonts`, `clipboard`, `wgpu`, `glow`, `accesskit`, `wayland`, `x11`; egui: workspace `monochrome_emoji_fonts` | `clipboard` restores the real OS clipboard; without it egui-winit simulates an in-process clipboard and cut/copy/paste, `RequestPaste` and the E4/W15P Wayland path are unavailable | +| `lightcraft-web` | eframe: `default_fonts`, `glow`; egui: workspace `monochrome_emoji_fonts` | browser clipboard is not the egui-winit `clipboard` feature; the browser path is qualified separately | +| `lightcraft-cli`, `lightcraft-ui-egui` | inherit the workspace `egui` features | headless rendering and kittest must resolve the same faces | + +`monochrome_emoji_fonts` adds roughly 1 MB to the binary (its own doc comment); the size impact is a +measured deliverable for both native and WASM (§7.2). Adding both faces must not weaken +`MissingGlyphPolicy`: production stays `Tofu`, kittest keeps its `Panic` default. + +**kittest does not supply these faces.** Its normal `[dependencies] egui` uses the workspace entry +with `default-features = false`; only `egui_kittest`'s own `[dev-dependencies]` enables +`default_fonts`/`monochrome_emoji_fonts` (`egui_kittest/Cargo.toml:39-42,65-68`), and a dependency's +dev-dependencies do not propagate to consumers. LightCraft's workspace/root feature selection is +therefore what gives every CLI, headless and kittest root its faces; verify each produced graph with +`cargo tree -e features`, as §5.3 requires. + +Current source/test gates (source-declared, not executed here): thousands of `#[test]` annotations; +`cargo xtask ci` runs fmt, clippy `--workspace --all-targets -D warnings`, the HEIF feature check, +`cargo test --workspace`, GPU probe/backends, `parity`, `layers`, `assets` and the wasm check +(`xtask/src/main.rs:295-366`). CI matrix files also cover FreeBSD (with craft-fonts), Nix, Windows +ARM64, packaging and release. `assets/ATTRIBUTION.md` matches every image, font, icon and media +path; migration screenshots stay in task evidence rather than the repository unless separately +approved and attributed. + +## 3. What actually changes in 0.37 (and what does not) + +The app already receives a root `egui::Ui` through `App::ui` and `Context::run_ui` on 0.36.2, and +`App::logic`/`App::ui` already exist in 0.36.2. Do not describe that split or root-Ui pass-through as +introduced by 0.37, and do not mechanically rewrite `update` into `ui` where no `update` exists. +`eframe 0.36.2` already calls `app.logic(ui.ctx())` before `app.ui(ui)` in each pass and calls +`run_logic` when hidden; the 0.37 source at `a4f9a17f` has the same shape (see §4.1). The real +incompatibilities and semantic risks are these. In the table, `egui/src/...` means +`crates/egui/src/...` in the egui checkout, and `egui_kittest/src/...` means +`crates/egui_kittest/src/...`; LightCraft paths remain repository-relative. + +| Change | Effect in LightCraft | Source anchor | +|---|---|---| +| `WidgetType` replaced by `accesskit::Role` (`egui::Role`), alias deprecated | `WidgetInfo` constructors and tests must use roles; `CollapsingHeader` becomes `DisclosureTriangle`, `SelectableLabel` becomes `Button`, `Checkbox` becomes `CheckBox` | `egui/src/lib.rs` 0.37 role alias/table; PR #8555 | +| `Id::new` deprecated in favor of `Id::unique`; `AsIdSalt`/`IdSalt` are explicit | **221** `egui::Id::new` occurrences on 218 lines plus **three** bare `Id::new` calls (`menu_level.rs:19`, `canvas_keys.rs:15,20`, imported with `use egui::Id`) and **8** `Ui::id()` calls; the remaining `*Id::new` spellings are `LayerId`/`FontId`/`TextureId`, unrelated. `Id::new` and `Id::unique` hash identically, so persisted egui state does not reset | `egui/src/id.rs`; `egui/src/ui.rs:363-381`; PRs #8534/#8586/#8542 | +| `Panel` constructors take `impl AsIdSalt`; panel ID is `parent_ui.scope_id().with_salt(self.id_salt)`, and `Panel::new` stores `IdSalt::new(id_salt)` | This is a **semantic change** from 0.36's exact `Id`. Code that removes/reads `PanelState` must use the same parent-salted key, computed from the original salt source. Passing an already-built `IdSalt` to `Panel::left/right` hashes it **again** (`IdSalt::new(IdSalt)` is a distinct salt — the id_salt.rs unit test `debug_format_nested_salt` proves nesting is not identity) | `egui/src/containers/panel.rs:284-300, 974-975`; `egui/src/id_salt.rs`; PR #8592 | +| `default_fonts` is no longer an `egui`/`epaint` default; monochrome emoji moved behind `monochrome_emoji_fonts` | The CLI, headless harnesses and any graph without eframe's features lose the bundled faces and the monochrome emoji; `FontDefinitions::default()` becomes empty and the font-count test changes meaning | `egui/Cargo.toml`; `epaint/src/text/font_definitions.rs:127-183`; PR #8583; §2.1 | +| eframe `clipboard` is a separate feature | Turning eframe defaults off silently selects egui-winit's simulated clipboard | `eframe/Cargo.toml:142-144,168-170`; `egui-winit/src/clipboard.rs` | +| `ViewportCommand::Screenshot` takes a one-shot `ScreenshotCallback`; `UserData`/`Event::Screenshot` are gone | `issue_screenshots`/`collect_screenshots` move to the CL-2F completion slot; the callback runs on the renderer thread, is `Send + 'static`, and must not touch app state | `egui/src/data/screenshot_callback.rs`; `viewport.rs`; PR #8489 | +| `Event::MouseWheel` gains `source: MouseWheelSource` | Every initializer in `control.rs`, tests, examples and headless helpers needs a source; use `Unknown` for the control channel | `egui/src/data/input/event.rs:171-195`; `mouse_wheel_source.rs` | +| `scroll_area::State.offset` is private; `clamped_offset`/`unclamped_offset` are the API | `menu_level` reads `.offset` and writes `st.offset.y`; `right` reads `.offset`. Use `clamped_offset()` for reads and a next-frame scroll target for writes; no private-field workaround | `egui/src/containers/scroll_area.rs:100-148` | +| Kinetic scrolling/rubber-banding and `MouseWheelSource` are new | Menu/wheel/scroll behavior can differ; `ui.scroll` must carry a source; tests cover wheel vs trackpad/momentum and overscroll | `scroll_area.rs` 0.37 | +| `ComboBox` is `ComboBox<'a>`; `Atom::custom` takes `IdSalt`; `WidgetAtomResponse::rect` takes `IdSalt` | `widgets::combo_box` needs a named lifetime; `arrow_button` uses `IdSalt::new("arrow")` consistently | `containers/combo_box.rs:45-101`; `atomics/atom.rs:98`; `atomics/widget_atom.rs:228-240` | +| `Ui::id` renamed `scope_id`; `UiBuilder::id` renamed `scope_id` | Replace the 8 `ui.id()` uses; child UIs use `scope_id`; `id_salt` remains for local children | `ui.rs:363-381`; `ui_builder.rs:59-82` | +| `Role`/AccessKit tree and strict kittest checks | kittest 0.37 panics on an input widget with no accessible name and on a missing glyph; `HarnessBuilder` defaults are `missing_glyph_policy: Panic`, `check_accessibility: true`, and the check runs after every settle and before every snapshot | `egui_kittest/src/builder.rs:46-60`; `accessibility.rs:26-42`; PRs #8589/#8611 | +| TextEdit click-away selection regression in 0.37 | `text_field`'s custom Cut/Copy menu and toolbar actions that read the selection can see a collapsed range; keep the existing memo/store logic and add the #8763 regression | upstream #8763, open; PR #8764 open/unmerged | +| `Style` defaults are roomier, TextEdit layout changes | LightCraft pins spacing/button padding/corner radii in `theme::apply_kind`, but menus, tooltips, scrollbars and plain dialogs can shift; review geometry/pixels, never auto-refresh | PRs #8617/#8445; `theme.rs:397-448` | +| PRIMARY selection support | New middle-click paste and PRIMARY publishing; verify separately from the normal clipboard, through E4 + W15P | PR #8428; E4; #8657 | +| `egui_extras`/`egui_kittest` have independent version numbers | Do not infer versions from `egui`; `egui_plot` is unused | crate manifests at `0.37.0` | + +Not a 0.37 migration item: `Context::root_ui`/`run_pass` (new in 0.37) are not needed by LightCraft's +`App::ui`, which already receives the root `Ui`. Never call `ctx.root_ui` from inside `run_ui`, +`App::ui` or `root_ui`; upstream #8535 documents the panic. Use the passed `&mut Ui`. Likewise, do +not claim `App::logic` is new or replace `App::ui` with a `Context` bridge. + +## 4. Concrete source design + +### 4.1 How the hosts actually call the app + +Verified statically at `a4f9a17f` (and unchanged in shape from 0.36.2): + +- **Native** (`eframe/src/native/epi_integration.rs`): `App::logic` and `App::ui` are both called + **inside** the `Context::run_ui` closure, so both run **once per layout pass** (a discard can + produce more than one). `raw_input_hook` is called once per host `update`, before `run_ui` (and + before `run_logic` on a hidden logic-only update), not per pass. +- **Web** (`eframe/src/web/app_runner.rs`): the same — `logic` then `ui` inside the `run_ui` + closure, per pass. +- **Headless** (`crates/ui-egui/src/headless.rs:320-325`): `Headless::step` calls + `HeadlessView::run(raw, |ui| { app.logic(ui.ctx()); app.ui(ui) })`, which is + `ctx.run_ui(raw, …)`; again per pass. +- **Hidden**: the integration calls `Context::run_logic`, which updates only window/focus state; + events, input time and layout remain from the last UI pass. +- After a completed `run_ui`, `current_pass_index()` is **0** (`ContextImpl::end_pass` takes the + output with `mem::take`; `run_dyn` restores the count only immediately before the next pass, and + `run_logic` does not restore an old index). During a second pass it is 1, 2, …. +- `ViewportInfo::visible()` derives from minimized/occluded and is `None` when unknown. **A hidden + window can still run a full UI pass**: `egui-winit`'s `ActionRequested::Screenshot` and + `PaintWhileHidden` both report `wants_paint()`, and eframe treats that as `is_visible` for the + pass (`wgpu_integration.rs:687-704`, `glow_integration.rs:680-693`). In that forced paint + `visible()` still reports `Some(false)`. + +Consequences: (a) `visible()` and fresh egui time are **not** a way to tell UI work from logic work; +(b) `current_pass_index() == 0` is a valid *known-UI-boundary* test for "first pass of this +`run_ui`", and it is also true during `run_logic`, so a hidden tick is never suppressed by it; (c) a +second pass has empty input events (`InputState::begin_pass` replaces events with `new.events`), so +event-driven code cannot run twice, while time/state-gated code can. + +### 4.2 The tick contract (no `TickPhase`, no visibility classifier) + +R1's `TickPhase { First, Later, Hidden }` and its time/visibility classification are **removed**. +They had no defined first-call, `visible() == None` or forced-hidden-paint branch, and their +stale-`current_pass_index` rationale was false. The replacement is a small, explicit split with a +monotonic clock and consumed queues: + +```rust +impl LightcraftApp { + /// Background/queue work. Called before layout on every host pass (native and web + /// inside `run_ui`, headless in `Headless::step`) and from `Context::run_logic` + /// while hidden. It may read `ctx.input(|i| i.viewport())` and `i.raw.focused` only + /// (those `RawInput` fields are refreshed by `run_logic`; `i.focused` and `i.time` + /// are not). Never events, dropped files or keys. + pub fn logic(&mut self, ctx: &egui::Context) { + i18n::set_language(self.ui.language); + panels::library_problem::logic(self); + panels::notices::logic(self); + if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + let now = self.tick_now(); // monotonic; see 4.3 + self.drain_control(ctx); // control_rx.take() + try_recv: consumed once + self.renderer.poll(ctx, &mut self.session); + merge::poll(self, ctx); + import::poll_scan(self, ctx); + sync::poll(self, ctx); + sync::poll_run(self, ctx); + tasks::poll(self, ctx); + import::tick(self, ctx, now); // time-gated; second pass is a no-op + lightroom_import::tick(self, ctx, now); + recheck::tick(self, now); + self.slideshow_tick(ctx, now); + self.auto_import(ctx, now); + self.retry_sidecars(ctx, now); + self.scan_devices(ctx, now); + self.preview_build_status(ctx, now); + self.save_status(ctx, now); + self.pump_background_queues(ctx, now); // faces/denoise/model/focus worker results + self.collect_captures(ctx); // completion slot: take once + self.issue_captures(ctx, now); // admit/issue; deadlines use `now` + self.session.persist_if_dirty(); + if !self.synthetic.is_empty() { + ctx.request_repaint(); // visible gesture steps + } + if self.needs_paint_while_hidden(ctx) { // pending gesture, queued capture or settle + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + } + + /// Input and once-per-layout-frame work: the known UI phase. + fn ui_pass(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if ctx.current_pass_index() == 0 { + self.take_dropped_files(); // from the queue filled once by raw_input_hook + self.pump_ui_input(&ctx); // the input-reading halves of faces/denoise + self.update_perf(&ctx); + } + self.sync_theme(&ctx); + self.apply_settings(&ctx); + if self.fonts_ready { + shortcuts::handle(self, &ctx); // fresh events only + } + self.check_external_editor(&ctx); + self.resolve_pending_gestures(); // after this pass's layout, see 4.4/4.5 + } +} +``` + +Rules that the implementation and its tests must keep: + +1. **One consumer per queue.** `drain_control` takes `control_rx` out, drains with `try_recv`, + puts it back; replies are sent exactly once. The screenshot slot is `take`n once. Worker results + carry a generation id and are applied once. There is no "run the queue again" path. +2. **`current_pass_index() == 0` guards once-per-host-frame work**: dropped-file consumption, + input-reading halves (`pump_ui_input`) and perf update, and `import::tick`'s batch commit. A + hidden `run_logic` also reports index 0, so the same gate lets import work advance on a hidden + tick. It is *not* the gate for the other background timers: Lightroom import, slideshow, + auto-import, sidecar retry, device scan, recheck, toasts and autosave live in `logic` behind + monotonic deadlines. A wall-clock gate cannot substitute for the pass index: a discarded first + pass that takes longer than the throttle would re-run it inside the same host frame. Where an + operation needs "how many frames have actually run", use `Context::cumulative_frame_nr()` (one + per `run_ui`), not the pass index and not `cumulative_pass_nr`. +3. **Input lives in the UI phase.** `shortcuts::handle`, dropped-file handling and the + external-editor focus check move out of `logic_inner` into `ui_pass`. A hidden `run_logic` tick + can no longer re-read the last pass's `dropped_files` (today it would spawn the same import + again) and cannot consume a queued key. +4. **Only refreshed window state is safe in `logic`.** `run_logic` refreshes + `RawInput::viewports`/`RawInput::focused` only; `InputState::focused`, `InputState::time` and + events stay from the last pass. Read `i.viewport()`/`i.raw.focused` in `logic`, and `i.focused`/ + `i.time` only in the UI phase. +5. **No freshness inference from visibility.** `Some(false)` + a running `App::ui` is a normal forced + paint (pending screenshot or `RequestPaintWhileHidden`); `None` is normal for headless and + `RawInput::default()`. Visibility must not be used to tell UI work from logic work. It *is* + used deliberately for user-facing pacing: a minimized/occluded (`Some(false)`) slideshow pauses + instead of advancing and requesting full-size slide renders, and resumes with its remaining + time on unhide (P2.5 in the post-port review). +6. **First call / first frame.** `tick_now` is initialised at construction and every timer is an + `Option`/deadline comparison that is true on the first tick, so `Headless::step` at time `0.0` + and `ctx.run_ui(RawInput::default(), …)` (whose input time is `prev + predicted_dt`, not 0) both + run their first logic work. Add explicit tests for both, plus `visible() == None` and a forced + hidden paint (§6.1). +7. **No `will_discard` prediction.** A later widget may request a discard after logic has already + run; correctness comes from one-consumer queues and idempotent side effects, not from guessing. + +### 4.3 Hidden-time accounting: one monotonic clock, painting stays egui time + +`run_logic` leaves `ctx.input(i.time)` frozen, so any timer read from it in `logic` is stale. +Introduce an app clock and use it for everything that must progress while hidden: + +```rust +/// Seconds since the app was constructed. Monotonic (`web_time::Instant`): never goes +/// backwards, even when the window is restored after hours hidden. +fn tick_now(&self) -> f64; +``` + +- Use `tick_now` for: `import::tick` (additionally gated by `current_pass_index() == 0` so a + discarded pass cannot commit twice), `lightroom_import::tick`, `recheck`, slideshow playback + (`panels/slideshow.rs:157-183`; auto-paused while `ViewportInfo::visible() == Some(false)`), + auto-import (`lib.rs:1154-1187`), sidecar retry + (`lib.rs:1190-1214`), device scan, screen-profile refresh (`lib.rs:1316-1322`), + denoise/faces/focus/model timers, autosave/prefs cadence, capture settle/fallback/timeout + deadlines, and **toast expiry**. Each stores a next deadline and advances it only when + `now >= deadline`, so a second pass in the same host frame is a no-op. +- Toasts: store the deadline as a monotonic app-clock value; `save_status` (`lib.rs:785-808`), + `preview_build_status` (`lib.rs:756-781`) and `panels::toast` (`panels/mod.rs:80`) read + `tick_now`, not `ctx.input(i.time)`. A hidden toast then keeps its remaining time instead of + expiring the instant the window is shown. +- Keep egui time for painting, animation, gestures and widget interaction (all of which only run in + the UI phase). +- The web host's `safety::take_notices` (`apps/lightcraft-web/src/web.rs:654-656`) runs in `logic` + and today uses `ctx.input(|i| i.time)`; give it the same monotonic deadline. +- `Headless` exposes its simulated time through the same clock (a test hook; the current + `Headless::time` starts at `0.0`). + +### 4.4 Duplicate-work inventory (narrowed) + +The second pass has **empty events**, so `shortcuts::handle`, dropped-file handling and +`collect_screenshots`-style event scans cannot run twice from one gesture. The real duplicate +candidates are state/time-gated calls that `logic` runs on every pass (a monotonic deadline makes +the second pass in a host frame a no-op), and one call that is literally duplicated today: + +- `panels::faces::pump` is called twice in one pass (`lib.rs:1139` and `:1228`); +- `import::tick`, `lightroom_import::tick`, slideshow timers, auto-import, sidecar retry, device + scan, recheck and the toast/status updates are time-gated, so a slow second pass could fire them + again; +- `WebApp::run_inbox` and `load_originals` drain queues on every call (correct — they are consumed + queues), while `import_dropped` re-reads `raw.dropped_files` on every call and must not run in a + hidden tick or a later pass; it moves to the UI phase and consumes an app queue filled once in + `raw_input_hook`. +- `panels::denoise::pump` (`denoise.rs:45-52`) and `panels::faces::pump` (`faces.rs:705-712`) + read `ctx.input` keys/pointer/`i.time` to pace background work. Split each into + `pump_background` (download/index worker results, monotonic deadlines — safe in `logic`, including + hidden) and `pump_ui_input` (fresh key/pointer activity and its timestamp — UI phase only). A + hidden tick must never pace work from the previous pass's pointer state. +- `WebApp::save` stays guarded by its own `last_save` deadline, now on the monotonic clock. + +There is no evidence that event-driven handlers need multi-pass guards, so do not add them. + +### 4.5 Control gestures while hidden (geometry, paint requests, one reply) + +Today `ui.clickWidget`/`ui.dragWidget`/`ui.hoverWidget` read `app.widgets` from the previous pass, +`ui.pointer` maps through the previous `app.image_rect`, and `raw_input_hook` injects one pointer +event (or one up-to-release key run) per host frame. A hidden request can therefore click stale +geometry, and a multi-event gesture can stall after one `RequestPaintWhileHidden`. + +New contract: + +- **Geometry is resolved only after a fresh layout.** Control requests that need geometry are + admitted into a `pending_gestures` list with their original params and `admitted_epoch = + layout_epoch`; `layout_epoch` (a monotonic counter, not a hash) is incremented at the **end** of + every completed `App::ui` pass. A request resolves, using that pass's `app.widgets`/ + `app.image_rect`, at the end of the first pass where `layout_epoch > admitted_epoch`. If no UI + pass has happened since admission, the request stays pending and the app sends `ViewportCommand::RequestPaintWhileHidden` (when + `ctx.input(|i| i.viewport().visible()) != Some(true)`) so the next host iteration paints. + If the session has never laid out a UI, reply with an explicit "no current UI frame" error at the + deadline instead of inventing geometry. +- **One unapplied step survives repeated hidden ticks.** `raw_input_hook` is called before + `run_ui` and before `run_logic` (`epi_integration.rs:338-365,413-430`), so a hidden logic-only tick + can invoke it. The hook injects at most one queued gesture step and marks it in flight; it must + not inject another step until a completed UI pass (tracked by a UI-pass counter incremented at the + end of `ui_pass`, never by `visible()`) has consumed that step. The step remains in eframe's + `pending_raw_input` across hidden ticks unchanged. This prevents two hidden ticks from batching a + press and release into one later pass, and preserves key-press-through-release semantics. +- **Each gesture step keeps painting.** While `synthetic` is non-empty (a drag has many moves; a + click is move+down+up) the app sends `RequestPaintWhileHidden` again after each step is consumed, + so hidden gestures complete instead of waiting for the user. The hook still injects only one + pointer move or one key sequence per host update. +- **Exactly one reply, after delivery.** The reply is deferred until the gesture's final synthetic + event has been injected into a UI pass (a `pending_gesture.delivered` flag set at the end of that + pass), or until the monotonic deadline, when it replies with an error. A dropped sender gets no + reply; a canceled/closed request is removed before replying. This makes a successful reply mean + "the UI saw the gesture", not "events were queued". +- `ui.key`, `ui.text` and `ui.scroll` need no geometry but do need a UI pass to be consumed + (`ctx.request_repaint()` alone only runs `run_logic` while hidden), so they follow the same + request-paint/one-reply pattern. +- `ui.screenshot` is **not** a gesture, but it has two phases. Once the + `ViewportCommand::Screenshot` is processed, `ActionRequested::Screenshot::wants_paint()` makes the + integration paint a hidden window. A capture that is merely queued or settling in `logic` has not + reached that phase: while hidden it needs `RequestPaintWhileHidden` from `logic` (the same + predicate as pending gestures) to reach a UI pass that issues the command. Test both the + issued-command forced paint and the queued/settling hidden case; do not expect a missing + issued-command paint to fail. + +**Implementation amendment (2026-10-24, before code).** The app owns the queue described above: + +- `layout_epoch` is a `u64` incremented once at the end of every **completed, nondiscarded** + `App::ui` pass (the same place `ui_passes` advances). `PendingGesture` records `admitted_epoch`, + an admission-time `GestureFrame` (view, active photo, library generation, dialog discriminant, + left/right/presets/filmstrip switches), a monotonic `deadline_ms`, the original method and params, + its reply sender, and the computed result once resolved. At most 32 may be pending; a 33rd is + refused by a normal control error. The deadline is 5 s from admission (`GESTURE_TIMEOUT_MS`). +- `ui.clickWidget`, `ui.dragWidget`, `ui.hoverWidget`, `ui.pointer`, `ui.move`, `ui.click`, + `ui.drag`, `ui.key`, `ui.text`, `ui.scroll`, `ui.zoom` and `ui.clipboard` are queued. Their + existing parsing/event construction moves to one `control::gesture_events(&mut app, ctx, method, + params)` function, called **only after** a completed layout newer than admission. Widget ids and + `app.image_rect` therefore always come from that fresh pass, never the previous registry. A + widget that no longer exists, a missing image rect, an invalid key/unit/phase or a removed text + focus answers once with the existing error, after that fresh pass (not at admission). +- Resolution happens in `finish_ui_pass` after `layout_epoch += 1`. Before computing geometry the + request's `GestureFrame` must still equal the current frame. A view, active-document, library, + dialog or panel-layout change between admission and resolution cancels the request with one + error instead of retargeting the new UI. The per-frame `layout_epoch` advance that makes the + layout fresh is expected and is not itself a cancellation. A request that never sees a completed + UI pass (hidden with no paint) answers `no completed UI frame` at its deadline. +- Resolution appends the gesture's synthetic events and marks the request resolved. It is **not** + answered yet. The reply is sent when `app.synthetic` is empty after a completed nondiscarded pass + (for a zero-event action such as a clipboard paste request, after the next completed pass), so + `{"ok": true}` means the UI consumed the last event. A hidden run keeps sending + `RequestPaintWhileHidden` while any gesture is unresolved or resolved-but-undelivered; a visible + run keeps `request_repaint`. +- The queue is fail-closed on timeout: the request is removed, its reply error is sent once, and + queued synthetic input is cleared so a late event cannot click after an error. This can discard + another queued gesture's still-undelivered events; those requests are answered with their own + timeout/cancel error rather than being retargeted. The queue is intentionally not a general + scheduler: immediate commands (`engine.execute`, `ui.set`, `ui.inspect`, `ui.resize`, + `ui.zoomFactor`, screenshot admission) keep their current direct semantics. +- Limit: the `GestureFrame` cancellation covers the navigation/modal/panel state that can change the + meaning of an automation id. It cannot prove that a widget with the same id kept the same + semantic target across an arbitrary catalog refresh inside the same frame; the fresh layout and + the explicit view/document snapshot are the contract, not a per-widget identity guarantee. + +### 4.6 Screenshot capture: the shared CL-2F weak completion slot + +Anchors: `crates/ui-egui/src/lib.rs:909-987` (`drain_control`, `issue_screenshots`), +`:1034-1057` (`collect_screenshots`), `:1502-1525` (`PendingShot`), `control.rs:432-439`, +`headless.rs:133-174` and `:310-349`. + +craft-libs selects one narrow completion-slot helper (`crates/ui/src/capture.rs`, CL-2) after the +DesignCraft and GridCraft adapters, then freezes the public API (CL-2F). LightCraft is one of the +craft-ui capture consumers and **adopts that helper** rather than keeping the local +`mpsc::Receiver`/token design; names and signatures come from the reviewed CL-2F revision, not from +this document. Every creative capture consumer adopts the CL-2F completion contract: +`craft-ui-consumers-exact.json` shows eleven apps whose TOML declares `craft-ui` (LightCraft among +them). All eleven require craft-libs **CL-1** and the frozen **CL-2F** API in the same atomic +dependency stage as egui/winit; only DesignCraft/GridCraft pilot the provisional CL-2 API, and their +adapters land before CL-2F freezes it. DeckCraft, FigCraft and ArtCraft Native use +contract-compatible local adapters without adding the crate, and all fourteen capture consumers +remain in scope. (The earlier "thirteen craft-ui consumers" wording was wrong; it is not repeated +here.) + +LightCraft's adapter (app-owned policy, shared completion transport): + +- **One record per admitted request**, owned by the app: monotonic request id, path, reply sender, + `headless`, admission/deadlines (settle ≥3 painted frames and ≥120 ms; compositor fallback 2 s; + hard timeout after admission), frames waited, reserved bytes, and a snapshot of capture-time + geometry (`content_rect`, `viewport_pixels`, `pixels_per_point`, UI zoom). "Frames" means host + frames (`Context::cumulative_frame_nr()`, incremented once per `run_ui`), not passes, so a + multipass frame counts once and does not make a screenshot fire early. Take the geometry from the + refreshed viewport information (`i.viewport().inner_rect`, `native_pixels_per_point`) when present, + otherwise from the last laid-out content rect; a hidden window has no new layout, so both are the + last known size, and the reserved bytes are revalidated against the returned image. +- **Issue** with a callback holding **only** a `Weak` reference to the app-owned completion slot; + the app owns the strong slot and a live, owner-specific completion waker. The callback receives + pixels only: no `Context` clone, no `&mut LightCraftApp`, no encoding, file I/O or command + execution. On completion it locks the slot, stores the image, unlocks, and only then invokes the + waker; it never wakes itself from inside the lock or re-enters the app. A late or canceled + callback finds no upgradable slot and is a no-op. The renderer owns the callback until it fires, + so this is not a `Context` reference cycle; `Weak` lets cancel/close drop the app state and makes + a late result harmless. A pending capture keeps `request_repaint_after(16 ms)`, which also runs + `run_logic` on a hidden native window, so the woken poll collects completion while hidden. +- **Completion**: take the slot once, release the byte reservation with a checked subtraction, and + bind the image to the **issuance** UI geometry, DPI and zoom epoch. Any change to logical viewport + size, surface pixels, `pixels_per_point` or UI zoom between issue and save cancels and retries (or + replies with an error at the deadline) — even if the geometry changes back to the original values + before the image arrives. Never combine old pixels with new geometry, and never accept an image + merely because its dimensions happen to match. Reject an image larger than the reservation and + release its bytes, then encode/write on the UI thread and reply exactly once. Derive the effective + scale from the returned image dimensions and the recorded logical viewport (E2 surface fitting can + shrink the image); record that with the result. Additive result fields (`scale`, `viewport`) are + optional and backward compatible. +- **Admission is bounded before issue** using the shared capture defaults: at most **4** + outstanding requests, at most **128 MiB** per image, and at most **256 MiB** aggregate reserved + in-flight/completed bytes. App-specific caps may be stricter, never looser. Require + `0 < width,height <= max_texture_side` (16 384 at inspection), compute the reservation with + checked `width * height * 4` before issuing, and revalidate it after any geometry/DPI/zoom change + before issue. Reserve the **worst surface the request can produce** — the full logical viewport × + `pixels_per_point` × UI zoom × 4 bytes — not the smaller E2-fitted size, and keep + completed-but-not-yet-drained images counted until they are taken (in-flight and completed bytes + are both checked before issue). The renderer's own readback allocation is accounted separately in + peak RSS; a post-callback size check alone does not bound peak memory. +- **Cancel/close/drop/timeout**: remove the record first; the late callback's `upgrade()` fails and + the image is dropped with no second reply and no panic. On shutdown all reservations return to + zero. Tests assert retained bytes reach zero after drain/cancel. + +**Implementation amendment (2026-10-24, before code): path identity at write time.** Admission pins +the destination as a `WriteTarget` owned by the app record: +`WriteTargetState::Absent` (the directory entry did not exist) or `Existing(FileIdentity)` (device +and inode on Unix; canonical path, length and creation time on Windows, whose volume/file-index +metadata is still unstable; canonical/len/mtime elsewhere; taken from `symlink_metadata` so a link +replacement is a different entry). Admission +still runs the library `OriginalGuard`. Immediately before encoding/writing, **both** the renderer +callback completion and the CPU/headless fallback call `revalidate_write_target`: +- an `Absent` target must still be absent; a file created after admission (ordinary file, imported + original, hard link or symlink) is refused, never replaced; +- an `Existing` target must still be the same directory entry and must pass `OriginalGuard` again + against the current catalog, so a photo imported at that path while the capture was queued is + refused; +- an existing allowed screenshot destination keeps its normal overwrite behavior. +An absent destination is written through the new host `write_new` service +(`lightcraft_engine::export::write_file_new` → `safe_file::write_new`): it prepares a complete temp +file and publishes it with `create_new`/hard-link semantics, so the final TOCTOU window cannot +replace a file that appeared. The bounded temp file is encoded bytes only. An existing destination +uses the existing atomic writer after revalidation. Failure to configure `write_new` for an absent +target is an explicit error (fail closed), not a fallback to the destructive writer. Limits: an +existing destination can still be swapped by another process in the instant between revalidation +and the atomic rename; closing that would require an OS conditional-replace primitive. The lexical parent is now re-resolved before completion and compared with its admitted +canonical parent; a retargeted directory symlink is refused. The deterministic tests cover an absent +path that becomes an ordinary file, an absent path that becomes a catalogued original through a +hard link, an existing original/symlink refused at admission, an existing allowed overwrite, and an +existing target replaced by a new entry while queued, and a parent symlink retargeted between +admission and completion (neither destination receives a screenshot). +- **Both headless entry points change**: `HeadlessView::run` retains `ViewportCommand::Screenshot` + callbacks and calls `callback.complete(Arc::new(self.paint()))` after the pass; `Headless::step` + deletes its `ViewportCommand::Screenshot(user_data)` arm (it no longer compiles in 0.37) and no + longer pushes `Event::Screenshot`. Completion happens exactly once, in the headless host. +- `headless_host` remains the CLI/headless flag; `WebApp` does not set it, and the web path has no + control server (§4.11, §8). +- Boundary: the shared piece is only the completion slot. Request ids, paths, settle delays, + deadlines, reply schema and `Services.write/png` stay LightCraft policy in + `lightcraft-ui-egui`; do not grow a generic screenshot framework in craft-ui for LightCraft. + +### 4.7 Panels, IDs and docking + +Anchors: `crates/ui-egui/src/panels.rs:52-77` (`resizable_side`), +`panels/bottombar.rs:24,371`, `panels/strip.rs:19`, `panels/topbar.rs:57`, +`panels/library_problem.rs:169`, `crates/ui-egui/src/docking.rs:430`, `shortcuts.rs:339`. + +`resizable_side` must pass the **original salt source** to the panel and derive the persisted key +from the same parent scope: + +```rust +// `Panel::new` stores `IdSalt::new(id)`; `Panel::id` is `parent_ui.scope_id().with_salt(store)`. +// `Id::with(salt)` is exactly `with_salt(IdSalt::new(salt))`, so this is the panel's real key. +let pid = ui.scope_id().with_salt(egui::IdSalt::new(id)); +ui.ctx().data_mut(|d| d.remove::(pid)); +let panel = if left { egui::Panel::left(id) } else { egui::Panel::right(id) }; +// ... show ... +if !ui.ctx().is_being_dragged(pid.with("__resize")) { + return None; +} +``` + +Why this exact form: `Panel::left(salt)` with an already-built `IdSalt` would store +`IdSalt::new(salt)` and derive `parent.scope_id().with_salt(IdSalt::new(salt))`, which is not the +`pid` above. The `resize_widget_id` is `panel_id.with("__resize")`, so the drag check matches only +when `pid` is the panel's real key. Do **not** add a public `panel_state_id()`-style method to +production code: the mapping is a local detail, and a public helper would invite callers to use a key +that the panel never stores. Tests derive the key themselves from the same `parent_ui.scope_id()` and +salt, or assert on the returned response rect and the `panel:*` widget registration. + +Scope of the risk, honestly: at this revision `resizable_side` has **no production caller** (the +shipped side panels are craft-ui `DockArea` panels and the `Panel::top/bottom/right` bars). The bug +is latent, not user-visible today; fix it anyway, and keep the mutation test so a future caller +cannot reintroduce it. The `bottombar` fixture currently reads +`PanelState::load(&ctx, Id::new("bottom_bar"))` (`bottombar.rs:371`); under 0.37 the key is +`ctx.viewport_id().root_ui_id().with_salt(IdSalt::new("bottom_bar"))` in that harness (the fixture +shows the panel in the root `Ui`), or read the response rect instead. Audit every +`Panel::top/bottom/left/right` id whose value is persisted or queried; fixed-size bars can keep +string salts, but tests must not assume the old exact key. + +The first compatibility PR also migrates the old panel state explicitly: 0.36 stored +`PanelState` under the exact `Id`, 0.37 stores it under the parent-salted key, so old entries are +unreachable. Do not try to read them: at start, remove the legacy exact-`Id` `PanelState` entries for +the known panel salts (`top_bar`, `bottom_bar`, `tool_strip`, `library-problem-banner` and any +`resizable_side` caller) with `data_mut(|d| d.remove::(legacy_id))`, document the one-time +panel-width/layout reset in the stage notes, and assert in a test that the new keys are used. No +user-visible document, workspace JSON, catalog or XMP data is migrated; this is egui memory only. + +Second window: it already uses `ctx.show_viewport_immediate`, and `CentralPanel::show` takes +`&mut Ui` in both 0.36.2 and 0.37, so this is not a 0.37 rewrite. Verify under 0.37 that embedded and +native callbacks both register `view:secondWindow`, render the active photo and close correctly. +`#8748` (running an overdue window's UI without painting so deferred children open/close while the +root is hidden) is closed upstream but **not present** in the inspected fork source (no +`PassMode::Overdue`): P7c in the required P8 revision owns the selected fix. The second window while +the root is hidden/minimized remains an explicit real-platform test, not an assumed fix. Do not call +`root_ui` from the viewport callback. + +Docking stays `craft_ui::docking::DockArea`. LightCraft is an LTR application and must state that +policy explicitly at both call sites (`docking.rs:430`, `shortcuts.rs:339`) with the reviewed +`DockArea::direction(DockDirection::LeftToRight)` builder from craft-libs §7.7 (exact name from the +reviewed revision). Defaults must not be relied on. Persisted split first/second, axes, ratios, +floating rectangles and logical tab order must be unchanged, and painting, clipping, hit testing, +AccessKit order and drag insertion must use the same rectangles. `UiState.docking` format is not +migrated; verify save/reopen and `ui.dock` behavior in `tests/docking_workflows.rs`, plus that LTR +geometry is byte-identical to the current behavior. + +### 4.8 Fonts, language, accessibility + +Anchors: `crates/ui-egui/src/theme.rs:260-332` (`install_fonts_with_chinese`, +`font_definitions_with_chinese`), `i18n.rs:218-223`, `text_field.rs:180-235`, +`widgets.rs:1004-1020`, `tests/accessibility.rs`, `tests_keywording.rs:558-583`. + +- Enable `default_fonts` **and** `monochrome_emoji_fonts` on the workspace `egui` dependency, and + keep eframe defaults off in both apps while adding native `clipboard` (§2.1). This is a LightCraft + root/workspace decision, not inherited from kittest: kittest's normal `egui` dependency is + `default-features = false`, and only its own private dev-dependencies enable the fonts, which do + not propagate. Verify every root (native, CLI/headless, kittest, wasm) resolves them through + `cargo tree -e features`/`cargo metadata`, not through the lockfile (§5.3); a consumer test root + without the workspace features would otherwise see an empty `FontDefinitions::default()`. +- Tests: assert `FontDefinitions::default()` contains `Hack`, `Ubuntu-Light`, `egui-icons`, + `NotoEmoji-Regular` and `emoji-icon-font` (`epaint/src/text/font_definitions.rs:128-159`; the + family key is `Ubuntu-Light`, not `Ubuntu`); assert U+1F642 `🙂` and U+1F44D `👍` are paintable in + the Proportional family and that an emoji keyword/filename renders without tofu; keep the existing + font-count relation (`theme.rs:700-705` compares against `FontDefinitions::default()`, so adding + the two default emoji faces keeps the relation valid — re-check, do not silently update). +- Font fixtures for measurement/edge cases come from the egui plan §10.6 pins: craft-fonts + `2481a8a324b986a45954e90045ed1f8f2efaaf3c` (Arabic/Thai/CJK SC/Japanese, manifest-recorded + SHA256 + OFL) and the test-only Hebrew variable font and Noto Color Emoji v2.047 with their + recorded SHA256 and licences. Fetch them into the task's fixture directory; **copy no font file + into this repository** and do not add them to `assets/ATTRIBUTION.md` (nothing new is committed). + `CRAFT_FONTS_DIR` stays the app's font input; a future bundled font belongs in craft-fonts. +- Keep `egui_system_font_provider`/`system_fonts` off unless a separate measured decision adopts the + startup/coverage trade-off: the product policy is bundled Inter + craft-fonts and must stay + deterministic. #8512's startup cost is a separate workload if that decision is revisited. +- Production `MissingGlyphPolicy` stays `Tofu`; kittest keeps `Panic` (`HarnessBuilder` defaults in + 0.37 are `missing_glyph_policy: Panic`, `check_accessibility: true`). **All** existing harnesses + are therefore strict from the first green compatibility stage: every unnamed input and every + missing glyph must be fixed (names via `Response::labelled_by`/`accessible_name`/ + `ui.label_inputs_by`, real fallback faces), never by disabling the check globally. A deliberate + tofu/unnamed probe opts out locally in that test only. +- Wire the fork's `Context::set_language` (`context.rs:2629`) from `i18n::language()` on start and + when it changes, so the AccessKit root language and bidi runs follow the locale. Command ids, + catalog data and user strings stay logical; this is presentation only. The in-process i18n + remains the source of truth. Do not import craft-libs #198 or any font asset. +- Keep `TextField`'s custom menu and selection memo; add the #8763 click-away regression (§6.3). + +### 4.9 Text input, IME, RTL, clipboard and PRIMARY + +- `Event::MouseWheel` initializers in `control.rs`, `headless.rs`, examples and tests get + `source: egui::MouseWheelSource::Unknown`; `ui.scroll` accepts optional + `"source": "unknown"|"wheel"|"trackpad"|"momentum"`, `"unit": "point"|"line"|"page"` and + `"phase": "start"|"move"|"end"|"cancel"`. Omitted values keep the pre-0.37 request meaning: + `Unknown`, `Point`, `Move`. Every value is validated before an event is queued; an invalid value + is a control error and changes nothing. This lets tests drive the grid/filmstrip + `MouseWheelUnit::Line` path and trackpad/momentum `TouchPhase::Start`/`End` cycles, which the + point/move-only API could not represent. `docs/control-protocol.md` documents the three optional + keys. Unit tests assert wheel/line/start, trackpad/point/end and the backward-compatible omitted + defaults. +- `raw_input_hook` keeps the §4.5 one-step-per-UI-pass contract; a logic-only tick must not drain + another step. `Event::PasteImage` and `MiddleClickPaste` are not app shortcuts — `keymap.rs`'s + recorder ignores them. +- **E21b/P7b is a new blocking prerequisite, not existing E21 behaviour.** Candidate egui-winit + never calls `WindowExtWayland::set_ime_surrounding_text`, and `egui::IMEOutput` carries no logical + text. The complete shared contract is in egui plan P7b and `ime-surrounding-gap.md`; LightCraft + must take all of it, not a weaker subset: + - owner/context-generation identity is present even when surrounding text is **Unavailable**. An + available **empty** document is distinct from unavailable and is sent explicitly as an empty + payload, not omitted; + - changing owner or available/unavailable support clears the cached text and commits the + protocol's disable/enable boundary (not merely empty text); same-owner ordinary updates preserve + composition; the old preedit is canceled, not inserted into the new editor; there is no + cross-window bleed; + - the final completed pass's descriptor, including an authoritative `None`, is what + `PlatformOutput::append` uses (replace `ime.or(self.ime)`); descriptor, owner and payload are + one atomic value; + - focus epochs and protocol serials are tracked separately: a current-epoch `done` with a + mismatched serial applies incoming edits **once** and defers outgoing state until a matching + serial; an obsolete owner/epoch invalidates its pending batch. Never drop valid incoming edits; + - the payload is bounded (≤4000 UTF-8 bytes, valid boundaries, preedit excluded, byte + cursor/anchor, explicit unavailable when a selection cannot fit) and the **new** payload path + allocates no full-document clone. Existing `TextEdit` change-detection/undo clones are a + separately measured baseline, not rewritten here; + - built-in `TextEdit` produces the payload after delete/commit/cursor updates (logical model + text, preedit removed, byte indices remapped) and the Wayland adapter sends it through W27. + LightCraft has no custom `IMEOutput` producer: its `TextField` wraps the built-in `TextEdit` and + other panels use `TextEdit` directly, so it **inherits** the producer and must test the inherited + behaviour. The family's custom editors (FilmCraft `panels/graphics.rs` and the others in + `ime-surrounding-gap.md`) need real logical-model/preedit/UTF-8-index adapters before family + acceptance; `None` placeholders are not a completed port. +- IME tests: outgoing surrounding text immediately after deletion-only and delete+commit batches; + preedit excluded; reversed selection; supplementary/combining/RTL text; cursor movement; a very + long line and an over-limit selection (explicit unavailable, not a truncated claim); available + empty vs unavailable; field and two-window switches; owner transition with committed + disable/enable; password/no-IME clears; focus loss/reset/late serial; a current-epoch serial + mismatch applying incoming edits once while outgoing waits, and a stale-epoch batch discarded; + multi-pass final output including authoritative `None`. One native Wayland + Vietnamese/predictive trace checks the outgoing protocol plus editor text and one undo + transaction. +- Bidi: a mixed Arabic/Hebrew + Latin filename and keyword stays logical in catalog/XMP while + painted text/caret/selection follow visual order, with no double bidi and no tofu when the + craft-fonts face is present. The UI stays LTR; do not enable `Style::right_to_left`. +- Clipboard: native `clipboard` feature (§2.1), normal cut/copy/paste through `ui.clipboard`, the + text-field menu, and PRIMARY middle-click. E4 and **W15P** are one compatibility unit: the + inspected E4 uses an arboard fallback and does not provide the required winit-owned per-seat + PRIMARY path. Require the complete P8/W15P revision: PRIMARY has its own protocol object on the + existing connection/event queue (SCTK 0.19.2 `PrimarySelectionManagerState`/device/source/offer; + never a second regular `wl_data_device`), and accepts a fresh trigger serial from **either a + pointer press or keyboard focus** on the focused seat — do not impose a keyboard-only rule. + Selection claims are synchronous while the trigger serial is fresh; serving and transfer are + asynchronous. P2b replaces blocking reads/writes on the egui event path with caller-owned bounded + async transfer handles: a total **2 s** deadline (not a per-`WouldBlock` poll timeout), a + **16 MiB** byte cap, at most **four** read workers and **two** writer workers with queue depth + **8**, an owner-specific live completion waker, and cancellation/discard of stale results when + focus, target, owner or input generation changes. Test on wlroots/KWin/Mutter: keyboard- and + pointer-triggered selection with valid per-seat serials, middle-click paste, password exclusion, + no reclaim of an unchanged selection, owner/seat change, empty/absent offer, self-paste, slow + trickle, oversized offer, cancellation, and UI event-loop responsiveness during a slow transfer. + A compositor limitation is recorded, not papered over with a control-channel substitute. + +### 4.10 GPU and renderer behavior + +Anchors: `apps/lightcraft/src/window_gpu.rs:104-200` (`choose`, `base_options`, `probe`), +`main.rs:917-940`, `crates/ui-egui/src/softpaint.rs`, `crates/ui-egui/src/render.rs`. + +- Keep the Windows probe/fallback design: DX12 → Vulkan → GL → glow, with error scopes and a + non-panicking wgpu error handler. `Renderer::new(device, format, RendererOptions)` is unchanged in + the inspected source; re-run the probe tests and the Windows startup matrix. +- E2/E3 (surface fit and configure/renderer error scopes) are required for very large or + multi-monitor surfaces; test a window larger than the device limit via + `LIGHTCRAFT_GPU_LIMITS=webgpu` and the fork's unit tests. A surface failure must be a typed error + and the next renderer/CPU fallback, never a panic. +- #8736 (DX12 FXC shader-compile cost) is measured as its **own** workload: the probe's pipeline + construction and eframe's first frame separately, fresh processes. A clamp-only shader change is + not a valid fix (it changes Repeat/MirroredRepeat semantics); evaluate the upstream/float-`select` + or pipeline-constant design with equivalent wrap tests. +- #8731 (plain-text tessellation) is its **own** workload: a text-heavy fixture (grid metadata, + menu labels, panel rows) with mixed color-glyph rows, baseline/stock-0.37/fork. Do not cache or + alter texture wrap/filter semantics to hide it. +- Photo textures remain `TextureOptions::LINEAR`; `softpaint`'s CPU mirror preserves nearest vs + linear per `ImageDelta` and texture wrap. kittest uses `RendererOptions::PREDICTABLE`; check + #8441's nearest-filter fix did not change preview/texture semantics. +- On DPI/zoom/resize, verify textures are updated/freed, the screenshot size contract stays + `viewport_pixels`/`resized_viewport`, and the softpaint shadow context uses the same UI zoom. + +### 4.11 Host adapters and persistence + +- Native `App::logic` becomes `self.0.logic(ctx);` then the native menu and `PrefsWriter::tick` + (both on the monotonic clock). `App::ui` stays one `self.0.ui(ui)`. Do not call `self.0.logic` + again from `ui`. `PrefsWriter::tick` must not record `Seen::of` window geometry from a hidden + `run_logic` pass whose `content_rect` is stale: keep the last known placement. +- Web `WebApp::logic`: `safety::take_notices` (monotonic), `run_inbox` (every call: consumed queue), + `load_originals` (every call: drains a queue), the worker budget, then `self.app.logic(ctx)`, then + `save` (own monotonic deadline), then the bench step (UI phase). `WebApp::ui` remains one + `self.app.ui(ui)` plus the first-frame log. `import_dropped` moves into the UI phase and consumes + the queue filled once by `WebApp::raw_input_hook`, so neither a later pass nor a hidden tick can + re-import the same drop. +- Keep `raw_input_hook` once per host frame; it takes synthetic events and dropped files into + app-owned queues, and the in-flight step gate in §4.5 prevents repeated logic-only ticks from + draining more than one synthetic step. Do not move injection into `ui`, where it would repeat on multipass. +- Keep `PrefsWriter` and `UiState` JSON unchanged. The only visible ID change is egui memory/panel + layout; `UiState` docking, settings, recent libraries, keymap and library catalog formats are not + migrated. Test edit → save → close → reopen and undo semantics with the existing library tests. + +## 5. Dependency graph, pin and lockfile acceptance + +### 5.1 Intended graph + +```text +lightcraft / lightcraft-web / lightcraft-cli / lightcraft-ui-egui + -> egui 0.37.0 (published storytold revision, immutable SHA) + + epaint, epaint_default_fonts, ecolor, emath + -> eframe 0.37.0, egui-winit, egui-wgpu, egui_glow + -> egui_kittest 0.37.0 (dev) -> kittest 0.5.0 (registry), accesskit 0.25.x + -> winit 0.30.13 (published storytold revision, immutable SHA) + -> craft-ui from the reviewed craft-libs revision (stock-egui-0.37 source compatible, CL-2F) +``` + +An incompatible `craft-ui` bump in any consumer is part of the same atomic dependency stage as the +egui/winit graph; never a separate version step that leaves the suite half-migrated. + +Root `[patch.crates-io]` must name every used package at the same full SHA: `egui`, `eframe`, +`egui-winit`, `egui-wgpu`, `egui_glow`, `egui_kittest`, `epaint`, `epaint_default_fonts`, `ecolor`, +`emath`, and `winit`. Add `egui_extras` only if it enters the graph. Do not patch `egui_plot`, +`egui_dock`, `egui_inspection` or `egui_system_font_provider` unless used. Never use a branch name +or a machine-local path in a publishable manifest. + +`craft-ui`'s own `[patch]` does not propagate: LightCraft's root patch is what gives +`eframe`/`egui-winit` the fork. A graph that resolves `winit` from crates.io while `egui-winit` +expects the fork API is a failure. `craft-ui` must compile against the stock egui 0.37 source API +(PR 199's role/`scope_id`/`IdSalt` edits) so the shared crate stays publishable; fork-only APIs stay +out of craft-ui. Pin `craft-ui` to the reviewed craft-libs revision and verify its `serde` feature +under LightCraft's root patch. + +### 5.2 craft-libs: use the reconciled base, never the stale trees + +- Current local shared-library base: `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`. It already + carries the egui 0.37 API port (the historical `8614e4a` shape and the `1764bca` README hunk); + those commits and the earlier `49190884278f16c9a9f5823d7698a8ffb5deb062` main are history, not + the base to apply again. +- PR 199 head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` and the audit merge + `ec8840af8c5fda695cf195cdd05e5ae078415c07` miss `craft-platform` and revert newer + security/settings/docking/printer fixes. Never merge either as a tree. +- Keep the `craft_ui::capture::CaptureSlot` consumer adapter on the reconciled base, preserve + every newer fix, and re-run craft-ui's own tests and visual fixtures after the port. The CL-2F + helper and the exact immutable publish SHA remain central-publication gates; neither is guessed + here. + +### 5.3 Checks + +Add an `xtask` dependency check (for example `xtask/src/deps.rs`, invoked by `cargo xtask ci` +before clippy). The check takes its expected graph from a mode/fixture: L1 runs it in 0.36.2 +baseline mode and is green today; L2a flips the expected graph to the 0.37 P8 graph in the same +atomic dependency commit. The negative fixtures run in both modes. `Cargo.lock` records packages, +versions and sources but **not resolved features**, so it can only prove identity: + +- lockfile/metadata identity: exactly one `egui`/`epaint` family source and one `winit` source, all + at the expected git URL and full revision; no registry copy beside them; `craft-ui` and + `craft-docpath` at the reviewed revision, not a branch or a path; +- resolved features from `cargo metadata` + `cargo tree -e features` for every accepted root + (native, headless/CLI, tests, wasm): `egui` has `default_fonts` and `monochrome_emoji_fonts`; + native `eframe` has `clipboard`; the wasm root does not accidentally pull `wgpu`; `craft-ui`'s + features resolve as intended. Record the raw output in the evidence; +- negative fixtures, each of which must fail: missing patch, branch pin, second registry `egui`, old + `craft-ui`, machine-local path, feature-less `egui` (the last one only through the + metadata/tree mechanism — a lockfile-only check cannot catch it); +- `wasm-bindgen` in the lock matches the CLI (`xtask/src/web.rs` already checks this). + +Run `cargo tree -d`, `cargo tree -i egui@0.37.0`, `cargo tree -i winit@0.30.13`, +`cargo metadata --locked`, `cargo check --locked --workspace --all-targets` and the wasm graph under +the same pin in managed jobs (§7.1). A clean checkout must fetch every approved revision and pass +`--locked` without local overrides. Recheck remote heads before merge; an immutable SHA is required +even if the remote branch moved. Record the lockfile SHA-256 and keep the 0.36.2 lockfile for +rollback. `accesskit` moves to 0.25.x, `kittest` to 0.5.0; review that churn. Nix's `cargoLock`/ +`allowBuiltinFetchGit` must fetch the git sources; run `nix build` if available, otherwise record +the explicit unavailable gate. + +## 6. Regression assertions + +### 6.1 Functional and lifecycle + +- **One command/undo step per user action under multipass.** Use the existing + `right.rs:1161` `request_discard` path plus a test-only forced discard, press one rating/flag key, + and assert one catalog op/undo entry, one toast, one repaint request and identical settings. + Repeat through the control channel. +- **Control gesture delivery and screenshot paint predicate (implementation amendment).** A queued + `ui.clickWidget` with a stale registry answers only after a completed fresh layout; an id that + disappears answers once; a view/dialog/document change between admission and resolution answers + once with a cancel error; a hidden gesture receives `RequestPaintWhileHidden` while unresolved or + undelivered and replies only after its last synthetic event is consumed; a 5 s deadline answers + once and clears the queued events. A screenshot that is only queued/settling (`sent_at_ms == + None`) requests a hidden paint; one already issued (`sent_at_ms == Some`) does not, while its + 2 s compositor fallback and hard timeout checks still run every logic tick. +- **Hidden logic continues.** Repeated `ctx.run_logic` calls while hidden advance + import/Lightroom-import/recheck/task/autosave/persist/retry/slideshow work, do not consume a queued + key/text/drop, and do not replay the last pass's dropped files. A hidden tick that invokes + `raw_input_hook` leaves one queued synthetic step in flight; repeated hidden ticks must not inject + further steps or batch press+release, and the next full pass consumes exactly one. Cover: a + completed multipass frame followed by hidden ticks (`current_pass_index() == 0` each time, so + nothing is skipped); `visible() == Some(false)`; a forced hidden paint with pending input and with + a pending screenshot; restore with buffered events (each consumed once); a worker completion + between passes. +- **First frames.** `Headless::step` at simulated time `0.0` runs logic and lays out; a harness that + calls `ctx.run_ui(egui::RawInput::default(), …)` runs logic; `visible() == None` is treated as + normal; the first forced hidden paint processes input. +- **Screenshot capture.** Two outstanding requests get their own image and exactly one reply; a + queued/settling hidden request receives `RequestPaintWhileHidden` before issue while an issued + screenshot forces its own paint; + complete out of order; drop/cancel then complete the callback (no reply/file/panic; reservation + back to zero); 2 s compositor fallback and hard timeout; an issued hidden-window request completes + via the screenshot's own forced paint while a queued/settling one first needs the paint request; + headless request completes through `HeadlessView::run` and + `Headless::step` (both, once); a resize between issue and completion retries or errors rather than + mixing geometry; oversized dimensions are rejected before issue. +- **Control gestures while hidden.** `ui.clickWidget`, `ui.dragWidget`, `ui.pointer` with + `space: "image"`, and a multi-step drag each complete while the viewport reports hidden, using the + first fresh layout after admission, send a paint request per step, and reply exactly once after + the last event was delivered. A widget that no longer exists replies with an error once. +- **Save/reopen.** Edit exposure/keywords/mask, save/persist, close, reopen the same synthetic + library; assert document meaning, selection/workspace and undo. Assert `UiState` JSON round-trips + and docking/floating geometry survives unchanged. +- **Docking and second window.** Real drag → float → close → reopen and `ui.dock` invalid requests + preserve the selected tool/layout (`tests/docking_workflows.rs`); explicit LTR direction leaves + saved rectangles and tab order unchanged; the embedded second window renders the active photo and + closes; the native second window focus/close/reopen is a real-platform test. + +### 6.2 Geometry, hit-testing and pixels + +At 1400×900 and 900×700, scales 1.0/1.5/2.0, dark/light themes: + +- top bar 42 pt, bottom bar 48 pt, tool strip 48 pt, default side widths from `UiState`; panel order + top → banner → strip → central → side panels; canvas rect equals the remaining rect within 0.5 pt; +- dragging the left/right inner edge by ±120 pt changes the stored width within 2 pt and the + registered `panel:*` rect within 1 pt; the photo area never falls below `MIN_PHOTO_WIDTH`; +- the `resizable_side` test asserts the drag edge is live through the corrected `pid` (and fails if + the panel is constructed with a pre-hashed `IdSalt`); +- grid/filmstrip/menu scroll offsets and hit rects agree after wheel, drag and resize; menu arrows + scroll and stop at bounds; +- dialogs are centered and fit; a long translated title/label does not overflow (`tests_dialog_fit`, + `tests_labels`, `tests_masking_layout`). + +Pixel comparisons use 1600×1000 at 1× and 2×, deterministic `Session::with_demo()` and pinned fonts. +Keep reviewed baselines in task evidence, never `UPDATE_SNAPSHOTS=force`; compare with a predeclared +metric: region geometry exact ±0.5 pt; count of pixels differing by >2 LSB and mean absolute +difference per region; a change in the top bar, side panels, canvas or menu requires review. +Determinism alone is not acceptance. + +### 6.3 Accessibility and keyboard + +- The strict kittest checks are on in every harness from L2a: no unnamed input, no missing glyph. + Extend `tests/accessibility.rs` to the roles `is_input` uses (`DisclosureTriangle`, `Splitter`, + `Tab`, inputs) and to the second-window embedded tree. Sliders have value/range; dialogs have + titles; hidden controls disappear. + Custom disclosure rows use the actual 0.37 API: `WidgetInfo::labeled(Role::DisclosureTriangle, + enabled, label)` plus `ctx.accesskit_node_builder(resp.id, |node| node.set_expanded(open))`. + They must not use `WidgetInfo::selected`, which fills AccessKit `Toggled` rather than `Expanded`. + A unit test asserts `is_expanded()` is `true`/`false` for `widgets::section_header`; the same + helper is used by `widgets::flyout_row`, the panel fold headers and the sidebar section headers. + (An accessibility-tree run over the Edit/Keywords views remains part of the broader accessibility + suite, not this focused state test.) +- Tab order stays top-to-bottom in panels; screen-reader Increment/Decrement/SetValue and + Enter/Space/arrow actions trigger the same commands as the pointer. +- Focus/edit/drag state does not transfer when the language changes, a panel hides, rows are + inserted, or a menu opens. `Id::unique` preserves `Id::new`'s hash, but a wrong `IdSalt`/`scope_id` + in a container can still move state. +- Text field: select range → click-away action uses that range (#8763); Esc restores; Return/away + commit; IME preedit/commit/cancel one edit/undo; bidi caret/selection in logical and visual order; + emoji keyword/filename renders without tofu. + +### 6.4 Mutation/negative controls + +Bounded probes that fail before the fix, kept as unit-level negative tests where cheap: + +- a capture slot completed for the wrong request: no file/reply; a late callback after cancel: no + reply/panic and released bytes; +- a deliberately duplicated command path under `request_discard`: the one-undo test fails when the + first-pass guard is removed; +- `resizable_side` with the old exact `Id` or a pre-hashed `IdSalt`: the drag test fails to see the + resize widget / does not store the width; +- one unnamed input in a scratch kittest harness: `check_accessibility` panics; +- one unsupported glyph: `MissingGlyphPolicy::Panic` panics and `Tofu` does not; +- a hidden `ui.clickWidget` while the widget registry is stale: the request resolves only after a + fresh layout (test fails if the old geometry is used). + +## 7. Performance protocol + +### 7.1 Baseline, candidate and control + +Build the baseline `23e18128`, the **stock** 0.37 control and the fork candidate in separate managed +`task.py run --kind build` allocations with distinct source/evidence paths (§8). Use the same Rust +toolchain (`1.99.0` at inspection), `--release`, same features (`denoise` default; `heif` separately), +same `CRAFT_FONTS_DIR` revision (craft-fonts `2481a8a`), same corpus files, same profile (including +`debug = "line-tables-only"`), same renderer/driver, viewport and DPI. Record full commit, lock +SHA-256, resolved features, fonts, fixtures, OS, CPU, RAM, GPU adapter/driver, renderer backend, DPI +and whether the GPU is enabled. Alternate execution order; never benchmark concurrently with a build +or another noisy workload. + +Fixtures: `Session::with_demo()` (deterministic), a 10,000-photo synthetic library from the +`grid_bench` generator, and the pinned 24 MP corpus raw (`cargo xtask corpus --download`; gitignored). +No corpus or `CRAFT_FONTS_DIR` was present at inspection, so these are explicit prerequisites. + +**Comparisons.** Report baseline 0.36.2 → candidate, and stock 0.37 → candidate wherever an +equivalent workflow exists in stock 0.37, so a fork-induced regression is not confused with a +0.37-induced one. The stock control uses the same fixtures, profile, fonts and environment. + +### 7.2 Operations and sample counts + +- **Warmups**: at least **10** warmup iterations per steady operation, discarded. +- **Steady operations**: at least **30** timed iterations (slider drag, menus, control operations, + opening photos), 10 repetitions for cold/warm open. +- **Startup**: **20** fresh processes per side, to first UI frame and to thumbnails settled; + separate process start, renderer/probe construction, font setup and first frame. +- **Frame distributions**: at least **1,000 actual painted frames** before any p50/p95/p99 claim + (not synthetic logic ticks, not 20/30/50 samples), counted from `Context::cumulative_frame_nr()` + or the equivalent painted-frame counter. Report median/p95/p99 with the actual frame count. +- **Grid**: `grid_bench` 200 and 10,000 photos, 60 unchanged frames, both grid modes, 1× and 2×; + median/p95, per-frame UI allocations, cells visited, layout rebuilds. +- **Slider drag**: `drag_latency` on the 24 MP raw, 27 controls, `TICKS=30`, CPU and GPU separate; + median/p95 draft texture latency and full render after release. Existing budgets: ≤16 ms draft, + ≤60 ms loupe; export ≤1 s/24 MP JPEG. +- **Thumbnails/menus**: `thumb_request_bench` with blocked rendering for allocation counts; a long + menu open with wheel/arrow scroll, 1,000 actual frames. +- **Opening photos**: `open_timeline` with pinned raws, warm and cold, 10 repetitions. +- **Control/UI operations**: `ui.screenshot` (headless), `ui.inspect`, grid scroll and panel drag, + measured request → reply over 30 samples. +- **Separate workloads, never one blended number**: (a) **#8731 text tessellation** — text-heavy + fixture, plain text vs mixed color glyphs; (b) **#8736 DX12** — fresh-process pipeline/probe + construction and first frame on Windows DX12; (c) **#8512 font startup** — font discovery/setup + time in a fresh process with and without the bundled faces. +- **Idle**: 60 s visible and 60 s minimized/hidden; process CPU time, `cumulative_frame_nr` (an + additive `perf.frames` only in the instrumentation stage), repaint count, GPU work and RSS. A new + busy loop or a repaint while hidden beyond requested work is a failure. +- **Web**: `cargo xtask web` **raw and compressed WASM size** (raw, gzip, brotli; separately record + the effect of `monochrome_emoji_fonts`), `?bench` first frame, thumbnails, eight slider steps and + release render, 10 fresh page loads per side, one browser/version and a software/ANGLE equivalent + if available. + +Record peak RSS, allocation/texture-upload counters, cold vs warm caches and idle CPU separately. + +### 7.3 Predeclared acceptance and noise policy + +Shared suite thresholds (do not loosen them): investigate a repeatable **>5 % median** or +**>10 % p95** latency regression, **>5 % peak-RSS increase**, any new idle busy loop/repaint, any +missed slider/loupe/export budget, or a visual/document regression. App-specific gates may be +stricter. Apply a documented noise floor: for sub-millisecond values require the relative increase +to exceed 0.05 ms or corroborate with stable instruction/allocation counts. The **stock 0.37 +equivalent control** is required in the first report and for every regression decision, so a +0.37-induced change is not attributed to the fork; stock 0.37 remains a control, never a production +stage. Reject noisy comparisons as inconclusive and rerun; **do not widen thresholds** after seeing a +failure. #8731/#8736/#8512 figures are upstream reports, not our measurements; compile duration is +not UI performance. + +## 8. Platform evidence and provisioning gates + +Every row is a **provisioned environment gate**, not a claim that the current host or a previous run +passes. A row without provisioned hardware/OS/browser is recorded as unresolved, never as a pass. + +| Platform | Required workflow | Acceptance | +|---|---|---| +| Linux X11 | Managed native run of the exact candidate with `--control PORT`, control script, native screenshot, edited/saved/reopened library; both themes, 1×/2×; renderer recorded. Requires a real X11 session (none is inherited by this server shell) | Real window; CPU and available GPU reported separately | +| Linux Wayland | Same on a named compositor (wlroots/KWin/Mutter) provisioned for the task; clipboard/PRIMARY, drop, IME, hidden second window | E4 + W15P matrix from §4.9; any compositor limitation recorded if reproduced | +| Windows | Owned `craft-windows` clone (own disk, UEFI, TPM, ports, 128 GiB reservation), native build of the exact candidate, visible grid/detail/edit workflow, screenshots, save/reopen, FXC/DX12 probe, software-renderer baseline and real GPU if present | Start-up/first frame measured; no panic/black window; DX12→Vulkan→GL fallback | +| Web | `cargo xtask web --serve` in a provisioned browser environment; browser automation drives observable `command(...)` actions (thumbnail/detail/slider/save state) and captures the canvas with the **browser's** screenshot facility; console errors checked; `?bench` | Real browser workflow; wasm module and CJK/emoji assets present; not just `cargo check`. There is no control server and `ui.screenshot` is not a `LightcraftApp::run` command (`web.rs:609-644` → `self.app.run`), so a browser capture is the only screenshot path unless a separate reviewed download/writer API is added | +| FreeBSD | Repo workflow with craft-fonts, if a runner is provisioned | Compile/test evidence or explicit unresolved gate | +| macOS | Real macOS hardware (not cross-compilation) for menu/IME/Metal/GL/termination/AccessKit teardown | Separate unresolved blocker | + +No claim is made that this server has no GPU: read-only inspection finds an i915 PCI `8086:3E96` +render device and readable/writable `/dev/dri/renderD128`. That is **availability evidence only**, +not a qualified Vulkan/OpenGL/compositor result. No display session is inherited, and Windows +baseline rendering remains software-rendered. + +Headless rendering is not proof of native focus, IME, file picker, compositor clipboard, screen +reader or GPU presentation. A windowed `ui.screenshot` that logs the "rendering headlessly" fallback +does not satisfy native acceptance. + +## 9. Reviewable PR stages, dependency DAG and rollback + +```text +P0 (external; BLOCKED until published) + storytold egui immutable revision P8: E1-E21 + P2b (E4 regular/PRIMARY with bounded + async W15P transfer) + P7 held-key pairing + P7b/E21b + P7c (#8763/#8748 and + justified perf fixes), reviewed and published + storytold winit immutable revision: complete W15P + W27 lifecycle (+ W15/W25/W26 series) + craft-libs CL-1 revision, then CL-2 provisional capture slot + CL-2 -> DesignCraft/GridCraft adapters -> CL-2F API freeze + | +L1 baseline harness/instrumentation + baseline-aware xtask dependency check + evidence scripts + (0.36.2 expected-graph mode; works today; no product UI change, no new pins) + | +L2a coherent graph + compile migration (manifests, lock, patch, Id/Role/scope/ComboBox/Event + source, default_fonts + monochrome_emoji_fonts + native clipboard, CL-2F screenshot + callback/admission/drain/timeout/basic reply handling, scroll offset read/write, strict kittest naming/glyph fixes, all -D warnings) + -- atomic; no unbuildable intermediate commit on main. BLOCKED on P0. + | +L2b lifecycle/root-UI hardening (tick split, monotonic clock, dropped-file queue, panel IDs, + hidden gesture queue, CL-2F capture stress tests, both headless hosts, second-window/docking tests) + -- needs CL-2F (DesignCraft/GridCraft are the CL-2 pilots). + | +L2c fonts/accessibility/language/IME/clipboard hardening (set_language, #8763, E21b/W27 tests, + W15P/E4 PRIMARY matrix) + | +L2d measured visual/perf acceptance, docs updates, platform evidence and final review +``` + +P0 is **explicitly blocked**: the final approved storytold immutable SHAs are unknown until the fork +and winit series are published, and this plan does not invent them. `a4f9a17f`/`8b35388b` are used +only to inspect behavior. L2a–L2d cannot be prepared as merge-ready changes until P0 exists. +L1 can start now in 0.36.2 expected-graph mode; L2a switches the dependency check to the 0.37 P8 +graph. L2a is one atomic dependency stage: the complete egui/winit P8 graph plus the required +craft-ui revision (CL-1 and CL-2F for the eleven `craft-ui` consumers; DesignCraft/GridCraft CL-2 +pilot adapters precede CL-2F). Any incompatible existing craft-ui bump belongs in that same atomic +stage. Stock 0.37 is only the measurement control, not a production stage. + +Expected file ownership: L1 adds `xtask/src/deps.rs` (plus dispatch), test/bench harness scripts +under `tools/egui037/` and non-product tests; L2a changes `Cargo.toml`, `Cargo.lock`, the three app +manifests, `crates/ui-egui/Cargo.toml` and the mechanical compile sites in +`crates/ui-egui/src/{lib.rs,widgets.rs,control.rs,headless.rs,menu_level.rs,panels/*}` plus +`apps/lightcraft/src/main.rs` where an API changed; L2b owns `lib.rs`, `panels/mod.rs`, +`panels/{faces,denoise}.rs`, `panels/second.rs`, `headless.rs`, `control.rs`, +`apps/lightcraft/src/main.rs`, `apps/lightcraft-web/src/web.rs` and lifecycle tests; L2c owns +`theme.rs`, `i18n.rs`, `text_field.rs`, `widgets.rs`, `tests/accessibility.rs` and the +IME/clipboard tests; L2d owns only reviewed visual/perf evidence and progress documentation. + +L2b–L2d are separate dependent PRs; they may be implemented on separate branches after L2a but merge +one at a time, re-running `cargo xtask ci` after each merge. Do not land a dependency bump that +leaves `main` unbuildable, and do not combine unrelated cleanup with the migration. + +Rollback is the whole previous coherent set, not an egui version string: revert the stage range and +restore the 0.36.2 manifests/lockfile plus craft-ui `5c874d51...` and registry winit `0.30.13`; keep +the old lockfile, patch block, package IDs and source SHA in evidence; keep fork/winit/craft-libs +revisions reachable. No persistence migration: `UiState`, catalog journal/snapshot, XMP, presets and +library paths are unchanged; only egui memory/panel layout may reset. No release, tag, branch +deletion or cache cleanup until the migration is abandoned or complete and evidence is preserved. + +## 10. Blocking unknowns and acceptance criteria + +1. **Published pins (blocking).** The final approved storytold egui **P8** (including P2b, P7, + P7b/E21b and P7c) and the completed winit (W15P + W27) immutable revisions do not exist until the + fork/winit series is published and reviewed; no SHA is assumed here. Stages L2a–L2d are blocked + on them. `a4f9a17f`/`8b35388b` are comparison references only; no stock or intermediate stage is + a production dependency. +2. **E21b/P7b (blocking).** The complete shared contract — owner identity even when unavailable, + available-empty distinct and explicitly sent, committed disable/enable clear, latest-pass + authoritative `None`, current-epoch serial mismatch applying incoming edits once while outgoing + waits, stale-epoch invalidation, bounded ≤4000-byte payload with no full-document clone — plus the + `TextEdit` producer and W27 adapter is not in the inspected candidate. LightCraft's built-in + `TextEdit` consumers inherit it, and the family's custom producers (e.g. FilmCraft + `panels/graphics.rs`) need their own logical-model adapters before family acceptance. +3. **W15P + E4 + P2b (blocking for native PRIMARY).** The inspected E4 has an arboard fallback, not + the required winit-owned per-seat PRIMARY path. Require the complete published P8/W15P revision + with bounded asynchronous transfers (2 s total deadline, 16 MiB, four readers/two writers/queue + 8, owner-specific waker, stale-result cancellation), pointer- or keyboard-triggered fresh serials + and the §4.9 native matrix. +4. **craft-libs CL-1/CL-2F.** The capture slot exists only as a provisional CL-2 API until + published; LightCraft requires the frozen CL-2F API, which only exists after the + DesignCraft/GridCraft pilot adapters. The port sits on the reconciled + `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` base; PR 199's head and the audit merge are stale + trees and `8614e4a`/`1764bca` are already contained in that base. +5. **Hidden viewport children.** #8748's overdue-UI behavior is not in the inspected fork; P7c is + required to carry the selected fix. The second window while the root is hidden/minimized still + needs native Wayland/Windows evidence. +6. **DX12 startup (#8736)** and **text tessellation (#8731)** are open upstream reports; measure on + our fixtures as separate workloads and require a semantics-preserving fix if thresholds are + breached. **#8512** is a separate font-startup workload, not a reason to enable system fonts. +7. **Strict kittest findings.** The number and identity of unnamed inputs/missing glyphs under 0.37 + is unknown until the first L2a run; they are real fixes, not a reason to disable the checks. +8. **Fonts.** Whether the bundled faces cover every shipped locale and script fixture without system + fonts is a measured gate; CJK tests skip without `CRAFT_FONTS_DIR`, which is not release + acceptance. Monochrome emoji add ~1 MB; measure native and WASM size. +9. **PRIMARY/clipboard on GNOME/Mutter** needs a real compositor; a control-channel paste is not a + substitute. +10. **macOS**: no real hardware evidence for menu, IME, Metal/GL, termination or AccessKit teardown; + an explicit rollout blocker, not a pass. +11. **Web workflow**: no control server and no `ui.screenshot` command; browser capture plus + observable `command` state is the acceptance path unless a separate download/writer API is + separately reviewed and approved. +12. **Visual deltas**: 0.37 style/scrollbar/menu changes may alter reviewed screenshots; no bulk + golden refresh. +13. **Lock/feature drift**: `accesskit`/`kittest` movement, `wasm-bindgen` CLI matching, Nix git + fetching and a second `egui`/`winit` source must pass the dependency check. +14. **Performance environment**: no display session, corpus or fonts are guaranteed on this host; + Linux/Windows/browser results need the managed resources in §7/§8. The i915 render node is + availability evidence only. + +Acceptance requires a fresh independent adversarial review of this plan, then of each implementation +at its exact revision, with resolved findings and hashes recorded. It also requires all repository +gates, selected features, native Windows/Linux and browser workflows, the measured before/after +report, accessibility and visual review, and explicit human publication approval. No unresolved +correctness finding is downgraded to "later". + +## 11. Research references (primary sources; proposals distinguished from merged behavior) + +- egui 0.37 changelog and release commit + [`841a328b`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/CHANGELOG.md); + the inspected suite branch is `a4f9a17f64a6bae61350fb85804c9df0d9865916`, not `main`. +- Lifecycle: [`epi_integration.rs`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/eframe/src/native/epi_integration.rs), + [`app_runner.rs`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/eframe/src/web/app_runner.rs), + [`context.rs`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/context.rs). +- Callback screenshots: PR #8489, `crates/egui/src/data/screenshot_callback.rs` (merged). +- Roles: PR #8555; `egui/src/lib.rs` role table (merged). +- IDs/scope: PRs #8534, #8586, #8590, #8542; `id.rs`, `id_salt.rs`, `ui.rs`, `ui_builder.rs` (merged). +- Panel salts: PR #8592; `containers/panel.rs` (merged). +- Root UI/run pass: PR #8535; `context.rs` (`root_ui` panic documented) (merged). +- Default fonts / emoji features: PR #8583; `egui/Cargo.toml`, `epaint/src/text/font_definitions.rs`. +- Strict accessibility/missing glyphs: PRs #8589, #8611, #8601; `egui_kittest` source. +- PRIMARY selection: PR #8428 (merged); Wayland clipboard #8657 (open draft) and winit #4729 (open), + paired with E4/W15P. +- Bidi/RTL: #8577 (open) and the fork's E5/E12/E13/E16/E17/E20/E21 series. +- TextEdit click-away selection: issue #8763, PR #8764 (open/unmerged at inspection). +- Text tessellation: issue #8731 (open); DX12 FXC pipeline: issue #8736 (open); system-font startup: + #8512 discussion. +- Hidden/overdue viewport children: issue #8748 (closed upstream; absent from the inspected fork). +- IME surrounding text gap: `ime-surrounding-gap.md` and the egui plan §E21b/P7b, winit W27. + +## 12. Evidence to preserve + +Retain: the baseline/candidate/control commit IDs, lock SHA-256 and package/source tables, +`cargo tree -d`/`-i` and `cargo tree -e features` output, `cargo xtask ci` reports and test +counts/skips, dependency-check negative fixtures, accessibility tree dumps, reviewed +baseline/candidate screenshots and geometry JSON, performance raw samples and summaries (with +environment and separate #8731/#8736/#8512 workloads), native/browser/Windows logs and screenshots, +the exact published egui/winit/craft-libs revisions and their tests, and this plan's reviewed hash. +Do not preserve build targets as evidence; they are rebuildable and are cleaned only after all checks +and deliverables are saved. + +## 13. Review disposition record + +### 13.1 R1 review (`lightcraft-v1.md`) + +| # | Finding | Disposition | Resolved in | +|---|---|---|---| +| 1 | `Panel::left(salt)` double-hashes; `pid` mismatch | **Accepted.** `Panel::new` stores `IdSalt::new(id_salt)` and `Panel::id` = `parent_ui.scope_id().with_salt(store)`; `IdSalt::new(IdSalt)` is a distinct salt (`id_salt.rs` nested-salt test). Fixed to pass the original source and derive `pid` with `with_salt(IdSalt::new(id))`; no public panel-state-id helper. Impact narrowed: the helper has no production caller at inspection, so the defect is latent today. | §3, §4.7 | +| 2 | eframe `clipboard` feature missing | **Accepted.** Add `clipboard` to the native app and assert it through resolved features; headless clipboard is not native acceptance. | §2.1, §4.9, §5.3 | +| 3 | `TickPhase` has no first-frame/`None`/forced-hidden branch; stale-index premise false | **Accepted, with two sub-claims rejected.** The stale-index claim and the `has_visible_pass` contradiction are correct. The specific example "`RawInput::default()` has time 0" is wrong: `InputState::begin_pass` uses `new.time.unwrap_or(prev + predicted_dt)`, so the input time is `predicted_dt`; the real zero-time first frame is `HeadlessView::raw_input(..., time = 0.0)`. The classifier is removed entirely rather than patched, so both cases are covered. | §4.1, §4.2, §6.1 | +| 4 | Hidden geometry-dependent controls use stale geometry; one paint request cannot carry a gesture | **Accepted.** Geometry resolves only after a fresh `layout_epoch`; each gesture step requests a hidden paint; exactly one reply, after delivery. | §4.5, §6.1 | +| 5 | `default_fonts` loses 0.36 emoji faces | **Accepted.** Enable `monochrome_emoji_fonts`; tests cover `🙂`/`👍` and the emoji keyword fixture; ~1 MB size is measured. | §2.1, §4.8, §7.2 | +| 6 | E4 does not provide the winit-owned PRIMARY path | **Accepted.** W15P is a prerequisite with the full native matrix; no limitation is recorded as "solved". | §1, §4.9, §10 | +| 7 | E21b/W27 outgoing IME surrounding text missing | **Accepted.** Added as a blocking prerequisite with the built-in-`TextEdit` inheritance tests and the family custom-producer requirement (FilmCraft graphics etc.). | §4.9, §10 | +| 8 | Web cannot invoke `ui.screenshot` | **Accepted.** Web `command()` only reaches `LightcraftApp::run`; browser capture plus observable commands is the workflow; no new app API unless separately reviewed. | §4.11, §8, §10 | +| 9 | A `Cargo.lock` check cannot assert features | **Accepted.** Identity from lock/metadata; features from `cargo metadata` + `cargo tree -e features`; feature-less fixture targets that mechanism. | §5.3 | +| 10 | `DockArea::direction` unspecified | **Accepted.** Explicit LTR policy at both call sites with unchanged persisted geometry/tab order. | §4.7, §6.1 | +| 11 | Screenshot design does not adopt the shared completion slot | **Accepted.** LightCraft requires the frozen CL-2F helper; request paths/deadlines/replies stay app-owned. | §4.6, §9 | +| 12 | Hidden-time accounting misses toasts/status and faces/denoise input | **Accepted.** One monotonic clock for toasts/status/timers; faces/denoise split into background drain vs UI-phase input. | §4.3, §4.4 | +| 13 | Duplicate-work claim overbroad; web inbox table contradiction | **Accepted with correction.** A second pass has empty events (`InputState::begin_pass`), so event handlers cannot duplicate; the real duplicates are time-gated ticks and the double `faces::pump`. Web inbox runs every `logic` call, dropped files only in the UI phase. | §4.2, §4.4, §4.11 | +| 14 | `Headless::step` still has the removed `Event::Screenshot` arm | **Accepted.** Both `HeadlessView::run` and `Headless::step` are updated; exactly one completion. | §4.6 | +| 15 | Count/factual issues (`221` id calls; web inbox table) | **Accepted.** 221 `egui::Id::new` occurrences (218 lines) plus three bare `Id::new`, and 8 `ui.id()` calls verified in this checkout; the table/text disagreement is removed. | §3, §4.4 | + +### 13.2 R2 review (`lightcraft-v2.md`) + +| # | Finding | Disposition | Resolved in | +|---|---|---|---| +| 1 | Hidden timers placed in `ui_pass` where hidden `run_logic` cannot reach them | **Accepted.** `import`/`lightroom_import`/`recheck`/slideshow/auto-import/sidecar/device/toast/autosave ticks were moved back to `logic`, with monotonic deadlines so a second pass is a no-op; only UI input/drop/perf work stays behind `current_pass_index() == 0`. | §4.2, §4.3, §4.4, §6.1 | +| 2 | E21b/W27 omission of owner/serial/epoch rules | **Accepted.** Restated the complete P7b/W27 contract: owner identity while unavailable, available-empty sent explicitly, committed disable/enable clear, latest-pass authoritative `None`, current-epoch mismatch applies incoming once and defers outgoing, stale epochs invalidate, bounded payload without a new full-document clone. | §4.9, §6.3, §10 | +| 3 | W15P required without bounded async transfer | **Accepted.** P2b is now part of the P8 prerequisite: 2 s total deadline, 16 MiB cap, four readers/two writers/queue 8, owner waker and stale cancellation, with slow/canceled-transfer and responsiveness tests. | §1, §4.9, §6.3, §10 | +| 4 | PRIMARY matrix is keyboard-only | **Accepted.** Fresh pointer-press or keyboard-focus serials are both valid; keyboard-only is explicitly rejected, and middle-click paste is tested. | §4.9, §10 | +| 5 | Hidden synthetic input can accumulate in `pending_raw_input` | **Accepted.** Hook may run in logic-only, so it injects at most one step and marks it in flight until a completed UI pass consumes it; no second injection from repeated hidden ticks and no press/release collapse. | §4.5, §6.1 | +| 6 | L1 cannot both run on 0.36.2 and enforce the 0.37 check | **Accepted.** The dependency check is baseline-aware; L1 runs in 0.36.2 expected-graph mode, and L2a flips it to the P8 graph atomically. | §5.3, §9 | +| 7 | Counts/detail inaccuracies | **Accepted, normalized.** This checkout has 218 `egui::Id::new` lines plus three bare `Id::new` calls (221 lines, 224 occurrences; the review's line/occurrence wording was slightly mixed). The font key is `Ubuntu-Light`; the `logic` doc comment now matches per-pass host calls; the weak-slot reason is corrected (the renderer owns the callback, so this is not a `Context` cycle). | §2.1, §3, §4.2, §4.6, §4.8 | + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +Canonical `storytold/lightcraft` `main` now resolves to `d5e639b3d1ad079c39a924c1b65d0a96ee2134ac`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +No migration API redesign required by this delta; preserve the merged packaging, test and chrono changes where present. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Local implementation — stage L2a/L2b source migration (2026-10-23) + +Status: **source changes only, not validated here.** No build, test, `cargo metadata`, fmt, +clippy, GUI, browser, Windows or performance run was made in this worktree. The parent runs the +managed jobs listed at the end. This section records exactly what changed and what is still +open; it is not acceptance evidence. + +### Dependency and feature stage (L2a) + +- Workspace `egui` is now `0.37` with `default-features = false` and + `features = ["default_fonts", "monochrome_emoji_fonts"]`; `eframe` and `egui_kittest` are `0.37` + (`egui_extras` is declared at `0.37` although LightCraft still does not use it). +- Native `eframe` adds the separate 0.37 `clipboard` feature; web keeps `default_fonts` + `glow`. +- The workspace feature choice is what gives the CLI/headless and kittest roots their faces: + `egui_kittest`'s own dev-dependencies do not propagate. `tests_lifecycle_037` asserts the + `Hack`, `Ubuntu-Light`, `egui-icons`, `NotoEmoji-Regular` and `emoji-icon-font` faces and that + U+1F642/U+1F44D are paintable. +- No final storytold SHA, `[patch]` entry or lockfile was invented. The parent supplies the local + path patch from `../PLANNING-CONTRACT.md` / `local-integration-config.toml` and generates the + lockfile. `crates/raw/src/vendor/` is untouched. + +### API migration + +- `egui::Id::new` → `egui::Id::unique` (same hash, so egui state does not reset) and `Ui::id()` → + `Ui::scope_id()` across the UI crate and tests. +- `WidgetType` → `egui::Role` with the documented variant mapping (`CollapsingHeader` → + `DisclosureTriangle`, `SelectableLabel` → `Button`, `ComboBox` stays `ComboBox`). +- `Panel` salts: `resizable_side` now passes the original salt to `Panel::left/right`, derives the + persisted key with `ui.scope_id().with(id)`, and checks the resize drag on that key (the old code + passed a prebuilt `Id` and would be salted twice). 0.36 exact-`Id` `PanelState` entries for + `top_bar`, `bottom_bar`, `tool_strip` and `library-problem-banner` are removed once at startup; + this resets only egui panel memory, never `UiState`, docking JSON, catalog or XMP data. +- `ScrollArea::State.offset` is private: menu arrows store a one-frame `vertical_scroll_offset` + target, and the right panel reads `clamped_offset()`. +- `Event::MouseWheel` initializers carry `source`; `ui.scroll` accepts optional + `"source"`, `"unit"` and `"phase"` with the validated values and defaults from §4.9. +- `ComboBox` has the documented lifetime; atoms take `IdSalt` (`Atom::custom`, + `WidgetAtomResponse::rect`). +- `ViewportCommand::Screenshot` now uses the one-shot callback and `Event::Screenshot`/`UserData` + are gone. Both headless entry points answer the callback with their painted frame exactly once. +- The fork's `Context::set_language` is fed from `i18n::language()` (the AccessKit root language + and bidi runs follow the UI locale; command ids and user data stay logical). + +### Lifecycle and capture stage (L2b) + +- `LightcraftApp` now has a monotonic `AppClock` (`web_time::Instant`, never `i.time` or + `Date.now` for timing; `now_ms` remains wall-clock only for `library-restored-` names). + Headless hosts feed it their simulated time; the first `Headless::step` runs at 0.0. +- `logic` keeps queues, worker polls and time-gated work. Import committing is gated on + `current_pass_index() == 0` (once per host frame, and once per hidden logic tick), not on the + clock; all other hidden-relevant timers (Lightroom import, slideshow, auto-import, sidecar retry, screen profile, denoise/faces/focus/model, + autosave/prefs, toasts, captures) use the app clock. +- `ui` is split into `ui_pass` (first pass only: fresh input, dropped files, external-editor focus, + shortcuts, perf) and `draw_window` (presentation), then `finish_ui_pass` (completed, nondiscarded + pass; clears the synthetic in-flight gate). A hidden `run_logic` never reaches it. +- Dropped files are taken once in `raw_input_hook` into an app queue and consumed only in the UI + phase; web consumes the same queue in `WebApp::ui` (it needs `bytes_async`). Native and web tests + cover that a hidden logic tick leaves the queue intact. +- `raw_input_hook` injects at most one synthetic batch, refuses to inject before fonts are ready, + marks the batch in flight until a completed pass consumes it, and keeps a hidden window painting + with `RequestPaintWhileHidden` while a batch, settlement or queued capture is pending. +- Screenshot capture adopts `craft_ui::capture::CaptureSlot` (CL-2 local tree): four outstanding + requests, 128 MiB per image, 256 MiB aggregate including completed-not-drained images, admission + before issue, fresh native viewport geometry, a three-completed-frame/120 ms settle, a forced + capture at 3 s even while renders are in flight, a 2 s compositor fallback and an 8.12 s + admission deadline (settle + forced capture + fallback + headless settle). The strong slot stays in the app request + record; the callback only holds a weak slot and never writes files or touches app state. A + completion from an older geometry epoch or library generation is retried, never written; an + image larger than its reservation is rejected; a cancel/timeout/late callback releases its bytes + with a checked subtraction and replies once. +- Web `Bench::step`, `WebApp` notices/save and the native `PrefsWriter` use the monotonic clock. + The native menu and window-watch no longer record a hidden pass's stale content rect. + +### IME inventory and next step + +LightCraft has no custom `egui::IMEOutput` producer: `TextField` wraps the built-in `TextEdit` and +every other editor uses `TextEdit`, so it will inherit the P7b/E21b logical-model/preedit/UTF-8 +producer once the reviewed egui revision is pinned. No `None` placeholder was added. The next step +is to re-run the P7b outgoing-surrounding-text cases (deletion-only and delete+commit, preedit +excluded, reversed selection, supplementary/RTL text, over-limit selection, available-empty vs +unavailable, window/owner switch, password clear, stale serial) against that pinned revision. The +incoming byte-deletion path is the built-in `TextEdit` path and is not reimplemented here; +`tests_lifecycle_037` asserts that `TextField` applies `ImeEvent::DeleteSurroundingBytes` +exactly (the pinned fork's winit byte-range event). + +### Tests added (source only) + +`crates/ui-egui/src/tests/lifecycle_037.rs`: + +- bundled monochrome emoji + explicit font-feature gate; +- parent-salted panel state (and no legacy exact-`Id` state); +- capture admission count/per-image/aggregate bounds before issue; +- stale-geometry completion rejected before any file write, reservation retained for retry; +- one synthetic batch until a completed pass, and dropped files waiting for the UI phase; +- first headless frame at simulated time 0 and the app clock following the host. + +### Unresolved prerequisites and validation + +- Final published storytold egui P8 (E1–E21 + P2b + P7 + P7b/E21b + P7c) and winit W15P/W27 + revisions, and the reviewed craft-libs CL-1/CL-2F revisions, do not exist as pins here. +- This source stage calls `craft_ui::capture::CaptureSlot` (the provisional CL-2 module in the + local craft-libs tree). The parent must provide a craft-ui revision that actually contains that + module; if CL-2F renames or changes it, the thin app adapter in `crates/ui-egui/src/lib.rs` must + be updated rather than copied into a second helper. +- Native PRIMARY (W15P/E4), E21b/W27 outgoing IME, #8748 deferred viewport children and #8763 are + inherited from those pins; none is claimed. +- The explicit LTR `DockArea::direction(DockDirection::LeftToRight)` policy is not added here: + the reviewed craft-ui revision in the local tree has no `direction` builder yet. Add it in the + same dependency stage that freezes CL-1/CL-2F; persisted splits, axes, ratios, floating + rectangles and logical tab order must stay unchanged. +- `accesskit` 0.24 → 0.25 and `kittest` 0.4 → 0.5 churn is not pinned here; check that the + Linux `zbus = "=5.19.0"` pin still resolves to one zbus source, and that `wasm-bindgen` in the + generated lock matches the CLI. +- `RequestPaintWhileHidden` has no web support in the shared fork: the browser path is unqualified + and remains a pending fork fix. Headless rendering is not native focus/IME/picker evidence. +- Strict kittest naming/missing-glyph checks may surface unnamed inputs or glyph gaps; those are + real fixes, not reasons to disable the checks. +- Visual/style deltas from roomier 0.37 defaults, Windows software rendering, macOS and native + compositor/PRIMARY evidence are still separate gates. +- Control gestures (`ui.pointer`, `ui.clickWidget`, `ui.dragWidget`, and the other input methods) + now resolve only after a completed fresh layout and reply only after the final nondiscarded pass + consumed their last event; the 5 s deadline is the fail-closed fallback. Limits are recorded in + §4.5: cancellation covers the admission-time view/document/dialog/panel snapshot, not a + per-widget semantic identity across an arbitrary catalog refresh in the same frame, and a + timeout clears queued synthetic input rather than removing one gesture's events individually. + +### Baseline backports and performance operations + +- Backports this consumer depends on but does not own: egui P7c (#8763 click-away selection, + #8748 overdue viewport children, the semantics-preserving text-tessellation fix) and E21b/P7b; + winit W27 outgoing IME lifecycle and W15P bounded PRIMARY; craft-libs PR 199's API-port hunks + (`Role`/`scope_id`/`IdSalt`) and the CL-2F capture helper on current main, never the stale PR + tree. +- Performance operations to measure after the pins exist, each as its own workload: menu/panel + text tessellation (#8731), DX12/FXC first-pipeline startup (#8736), system-font startup only if + the font policy is revisited (#8512), capture settle/issue overhead and hidden idle repaint/CPU. + Record raw samples and the environment; do not treat compile time as UI performance or refresh + visual goldens in bulk. + +### Parent-managed validation commands + +Use one new resource (for example `egui037-lightcraft-candidate-20261023`) and a fresh job id per +command. `--locked` only after the parent generates the lockfile. + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id egui037-lightcraft-20261023-check-01 --kind build \ + --task egui037-lightcraft-candidate-20261023 \ + --source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \ + -- cargo check --workspace --all-targets +python3 /home/codex/work/maintenance/task.py run \ + --job-id egui037-lightcraft-20261023-lifecycle-01 --kind build \ + --task egui037-lightcraft-candidate-20261023 \ + --source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \ + -- cargo test -p lightcraft-ui-egui --lib tests_lifecycle_037 +python3 /home/codex/work/maintenance/task.py run \ + --job-id egui037-lightcraft-20261023-ci-01 --kind build \ + --task egui037-lightcraft-candidate-20261023 \ + --source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \ + -- cargo xtask ci +``` + +Then inspect every `status`, record `cargo tree -e features` for the native, CLI/headless, kittest +and wasm roots, run the accessibility/docking integration tests and the `cargo xtask web` browser +workflow, and only afterwards consider the measured before/after workloads (menu text tessellation, +DX12/FXC startup, capture settle cost, hidden idle repaint). Do not refresh visual goldens in bulk. + +## Post-port review repairs (2026-10-23) + +Source-only repair of `egui037-lightcraft-postport-review-20261023-root-v1.md`. No build, test, +fmt, clippy, `cargo metadata`, GUI, browser, Windows or performance run was made here; the parent +submits the managed jobs. + +- **P1.1**: the four bare `Context::run_ui` statements in `tests_lifecycle_037.rs` bind their + `FullOutput` (`let _ = …`) so `-D warnings` no longer sees `unused_must_use`. +- **P2.1**: a settled capture is issued at a forced instant (`SHOT_FORCE_BUSY_MS`, three seconds + from admission) even when `renderer.in_flight()` or the merge is busy; the hard deadline (five + seconds) stays the only timeout path. `shot_ready` is a pure helper with a busy-renderer test. +- **P2.2**: capture identity is the library generation only. `catalog.revision` (bumped by every + applied edit/import/keyword op) no longer invalidates an in-flight image; the test applies one + catalog op between issue and completion and still writes the image. +- **P2.3**: a surface that grows after admission is checked as an *image* against the 128 MiB + per-image cap and only its growth against the 256 MiB aggregate. The old code compared the delta + to the per-image cap and named the delta as the image. +- **P2.4**: a completion larger than its reservation but within the per-image cap is treated as a + stale reservation (Wayland has no `inner_rect`), re-reserved and written, not rejected. +- **P2.5**: a slideshow auto-pauses while `ViewportInfo::visible() == Some(false)`, requests no + playback repaint, and resumes on unhide with the slide's remaining time preserved. This replaces + the 0.36 behavior where `i.time` froze while hidden. +- **P2.6**: the “0.37 has no `visible()`” rationale was wrong. `lib.rs` and + `apps/lightcraft/src/main.rs` now use `ViewportInfo::visible()`; §4.1/§4.2 above are corrected. +- **P3.1**: `import::tick` is gated by `current_pass_index() == 0` (once per host frame, and once + per hidden logic tick), not by a wall-clock throttle. The wall-clock comment was false. +- **P3.2**: the app retains the host's last `RawInput::max_texture_side` (default 16 384) and reports + “no usable surface” and “over the host's per-side texture limit” as distinct admission errors. +- **P3.3**: screenshot encoding no longer makes the extra flat RGBA `Vec` copy; `Rgba8::from_pixels` + fills the image's own buffer once. `MAX_SHOT_WORKING_SET_BYTES` documents the bound: reserved + images (≤ 256 MiB) + one `Rgba8` copy + encoded PNG (each ≤ 128 MiB) + 64 KiB zlib/framing slack. + Encoding stays on the UI thread, one image at a time. The MCP layer's decode/base64 transient is + outside the app process path and remains bounded by its own 1600 px default. +- **P3.4**: `apps/lightcraft-web/src/bench.rs` now measures `first_frame_ms` and `thumbs_done_ms` + from `WebApp` construction (`monotonic_ms()` origin), not navigation start. Old + `lightcraft-bench` samples are therefore not comparable; record the origin with every sample. + `web.rs`'s “first UI frame at perf_now()” log is a different origin in the same file and is + diagnostic only. +- **P3.5**: `Headless::step` no longer carries a second `ViewportCommand::Screenshot` completion + arm; `HeadlessView::run` is the only headless completion point. +- **P4**: the AccessKit language is cached instead of allocating through `Context::language()` on + every logic pass; the wasm drop branch names `WebApp` as the queue consumer; the legacy panel + eviction comment now says the old keys are unreachable anyway and only keeps egui memory tidy, + with a test that the four legacy keys are removed and the parent-salted keys survive. + +Still open from the review: P3.3's MCP decode/base64 transient (documented, not reserved), the +web `RequestPaintWhileHidden` gap, #8748/#8763/E21b/W15P/W27, strict kittest findings, and all +runtime/visual/performance acceptance. The tests above are deterministic source oracles, not +runtime evidence. + +## Review-test repairs — 2026-10-24 (source only) + +The managed review-test job `egui037-lightcraft-candidate-20261024-review-tests-13` reported +1155 library passes, 106 failures, 4 ignored, plus 7 of 8 integration tests and the shutdown test +passing. Grouping the failures gave two root causes, repaired here without relaxing a check: + +- **Gesture delivery re-checked its own effect.** `reply_delivered_gestures` compared the + admission-time view/document/dialog/panel snapshot again after the batch had been consumed. Every + click or key whose purpose is to change that state (opening a dialog, leaving the grid, closing a + dialog, selecting another photo) was answered with “changed before delivery” although the input + had done its job. Delivery now requires only that a completed, nondiscarded pass consumed the + batch. Freshness stays fail-closed twice: at resolution (a later completed layout must match the + admission snapshot) and in `drop_gestures_stale_before_injection` immediately before a waiting + gesture's first batch is injected, so a hidden logic tick cannot retarget it; later batches of + the same gesture may see its own partial effect, and a concurrently queued gesture is checked + when its events reach the head. A changed snapshot before the fresh layout still cancels, and a + stale pre-injection batch is cleared and answered with an error. +- **Unlabelled dropdowns.** egui 0.37's `ComboBox::from_id_salt` publishes the selected text as the + node's value but no accessible name, and kittest 0.37's strict accessibility check rejects it. + `widgets::name_combo` now gives every dropdown a semantic label (copyright status, keyword set, + proof profile, denoise limit, smart-album match/field/operator/value, export/contact-sheet/import/ + settings/credits/print dropdowns), so no strict check was disabled. + +One lifecycle test assumed the app opened in the photo grid while `UiState` defaults to Detail, so +its “view changed” setup was a no-op; it now changes to PhotoGrid explicitly. New regression tests +cover a delivered gesture that opens a dialog (stays `ok`) and a view change between resolution and +injection (answers once with `canceled` and injects nothing). + +No build, test, fmt, clippy, GUI, browser, Windows or performance run was made for this amendment; +the parent runs the managed jobs. The 106 failures and the ComboBox finding are the only failures +inspected in that output; any further finding is reported separately rather than folded in here. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e8b86a670..8bf7d6d6f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | PSD / PSB | ✅ (maximized-compatibility composite) | ✅ (Classic: PSD export, edit-in round trip) | ✅ merged composite | ⬜ (external-editor round trip writes 16-bit TIFF) | `crates/codecs/src/psd.rs` | | HEIC / HEIF | ✅ | ⬜ (Classic) / HDR only | ✅ in every release build (`crates/heif`, heic-rs) | ⬜ | `heif` feature on by packaging scripts | | AVIF | ✅ | ✅ (HDR) | ⬜ recognised, not decoded (no permissive pure-Rust AV1 decoder) | ✅ 8/10-bit, PQ HDR | | -| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests_export.rs` | +| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests/export.rs` | | WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target | | GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target | | DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed, LJ92, Deflate or lossy (JPEG tiles, optional long edge); ⬜ embedded original | `crates/raw/src/dngwrite.rs` | diff --git a/docs/focus-points.md b/docs/focus-points.md index 2171a7c15..e17e60d1d 100644 --- a/docs/focus-points.md +++ b/docs/focus-points.md @@ -113,7 +113,7 @@ DMF (AF, then manual touch-up) keeps its point. - `crates/meta/src/focus_read.rs`: the header-only read; its tests check it against the whole-file read on intact, truncated and corrupted files and count what it reads. - `crates/engine/src/focus_scan.rs`: the background job (`photo.readFocusPoints`, `focus.pump`) and - `crates/engine/src/tests_focus_scan.rs`, with an opt-in comparison over real files + `crates/engine/src/tests/focus_scan.rs`, with an opt-in comparison over real files (`LIGHTCRAFT_FOCUS_FILES=`); `crates/ui-egui/src/focus_scan.rs` drives it from the frame loop. - `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's - rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. + rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests/focus_point.rs`. diff --git a/docs/gaps.md b/docs/gaps.md index ceea3724f..42d9d437b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -43,8 +43,8 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | -| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target, but the Info panel and All Metadata dialog always show one photo's values with no "mixed" indication when selected photos differ — users cannot tell whether a field is uniform or mixed across the selection before editing | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 4–8 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), ⌘⌥⇧C / ⌘⌥⇧V (Copy / Paste Metadata), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | +| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target. The Info panel's Show Metadata For choice (Target Photo / Selected Photos) shows the values the selected photos share and marks each field where they differ as Mixed; the All Metadata dialog still shows one photo's values with no "mixed" indication | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 1–3 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -84,6 +84,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | +| 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | | 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours | | 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed | diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 81bc2cede..98ae0558a 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -17,7 +17,10 @@ Select photos in Photo Grid or Square Grid, then use: | `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | | `\` (backslash) | Show / hide the Filter Bar | +| ⌘K / Ctrl+K | Add Keywords: show the Keywords panel with the cursor in its Add keyword box | | ⌘L / Ctrl+L | Enable Filters: switch the filter off and on | +| ⌘⌥⇧C / Ctrl+Alt+Shift+C | Copy Metadata: copy the active photo's metadata | +| ⌘⌥⇧V / Ctrl+Alt+Shift+V | Paste Metadata: paste the copied metadata on the selected photos | Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail. @@ -83,7 +86,18 @@ Survey and Reference, and leave text fields alone, so the search box still types commands (`view.thumbSizeUp`, `view.thumbSizeDown`, reachable from the control channel and MCP, which return `{"size"}`); the size is saved with the other interface settings. -Public behaviour references, checked 2026-10-08 (thumbnail size: 2026-10-13): +## Keyword sets + +`⌥1`–`⌥9` (`Alt+1`–`Alt+9` on Windows and Linux) add or remove the current keyword set's keywords on +the selected photos. `⌥0` makes the next keyword set current and `⌥⇧0` the previous one (`Alt+0` / +`Alt+Shift+0`), in the order of the Keywords panel's Keyword Set menu: Recent Keywords, then the saved +sets. After the last set comes Recent Keywords again, and before Recent Keywords comes the last set; +with no saved sets the keys leave Recent Keywords current. The shortcut list does not say what happens +past either end; wrapping around is LightCraft's choice. The same steps are the Next / Previous +Keyword Set commands (`keyword.nextSet`, `keyword.previousSet`), which return what `keyword.sets` +returns, and both keys can be reassigned in Help → Keyboard Shortcuts. + +Public behavior references, checked 2026-10-08 (thumbnail size: 2026-10-13; keyword sets: 2026-10-24): - [Lightroom Classic keyboard shortcuts](https://helpx.adobe.com/lightroom-classic/help/keyboard-shortcuts.html) - [Lightroom Classic flagging, labelling and rating](https://helpx.adobe.com/lightroom-classic/help/flag-label-rate-photos.html) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 448ab8afe..d8d251359 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-MERGE-HDR / LR-MERGE-PANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -69,13 +69,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | -| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests_drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | -| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | -| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | -| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`) | +| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests/drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | +| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests/import.rs` | | +| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/dialogs/import.rs`) | +| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/dialogs/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | -| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | +| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/dialogs/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | @@ -84,8 +84,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/it/corpus.rs`, `crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `crates/raw/src/dng.rs` (`decode_reduced`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | -| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | -| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | +| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests/import_move.rs`, `crates/ui-egui/src/dialogs/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | +| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/dialogs/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | ## B. Library management (LIB) @@ -94,13 +94,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | -| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests/albums_local.rs`, `crates/ui-egui/src/tests/panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests/panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:library.holdView`, `cmd:library.refresh`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; views and source counts with direct or nested relative-date rules expire on the session-clock minute; sidebar counts remain cached without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit or clock expiry made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort, while newly matching photos enter at once | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | -| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/rating.rs` | one shared star row in the bottom bar, Info panel and filter bar (hover previews, ←/→ on a focused star) and one painter for badges (grid cells, filmstrip, Compare caption); menu rows with star text (Photo ▸ Set Rating, Select By Rating, the grid context menu) are separate menu items, unchanged; 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. A rating, flag or label that takes the active photo out of the view (filter bar, Picks, smart album) makes the photo after it active (the one before at the end), and Shift / Auto Advance doesn't step past it (#2312). Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/widgets/rating.rs` | one shared star row in the bottom bar, Info panel and filter bar (hover previews, ←/→ on a focused star) and one painter for badges (grid cells, filmstrip, Compare caption); menu rows with star text (Photo ▸ Set Rating, Select By Rating, the grid context menu) are separate menu items, unchanged; 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. A rating, flag or label that takes the active photo out of the view (filter bar, Picks, smart album) makes the photo after it active (the one before at the end), and Shift / Auto Advance doesn't step past it (#2312). Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | @@ -108,7 +108,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges (a click expands / collapses the stack, selected or not), expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | -| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | +| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests/album_remove.rs`, `crates/ui-egui/src/tests/album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. A capture time that doesn't fully read (cut short, or text after it) is left as it is and counted (`unreadable`); it can't anchor a set that moves other photos, and set alone repairs it. | @@ -130,10 +130,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | -| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | -| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests/before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo, each tile's caption shows its colour label, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions`, `crates/ui-egui/src/panels/detail.rs` (`info_overlay`) | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel); the star rating and flag show under the lines when the photo has them, and in the full-screen preview on macOS the overlay starts below the window controls (issue #2460) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | @@ -164,12 +164,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | -| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks Selected Photos (the default: the values they share; a text field or GPS where they differ is empty and says Mixed, Copyright Status shows Mixed, and only what is typed or picked there is saved, to every selected photo) or Target Photo (the active photo's values; an edit goes to that photo alone). No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | +| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/dialogs/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | -| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests/xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | +| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests/xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -190,7 +190,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | -| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | +| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests/wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | | LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | @@ -198,7 +198,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | | LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); a wheel drag holds the pointer as in Lightroom: hidden while dragging (the puck follows the mouse's travel; on macOS the pointer is locked in place), back where the button went down on release, and given back at once on Esc, a lost window focus or a wheel that goes away mid-drag; the browser can't move the pointer, so there it only shows again where the drag ended (issue #1899; tests `a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits`, `a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed`, `escape_or_losing_focus_gives_the_pointer_back_mid_drag`, `a_wheel_that_goes_away_mid_drag_gives_the_pointer_back`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests/grading.rs`); a wheel drag holds the pointer as in Lightroom: hidden while dragging (the puck follows the mouse's travel; on macOS the pointer is locked in place), back where the button went down on release, and given back at once on Esc, a lost window focus or a wheel that goes away mid-drag; the browser can't move the pointer, so there it only shows again where the drag ended (issue #1899; tests `a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits`, `a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed`, `escape_or_losing_focus_gives_the_pointer_back_mid_drag`, `a_wheel_that_goes_away_mid_drag_gives_the_pointer_back`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | @@ -218,12 +218,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | -| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests_constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests/constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) | -| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `cmd:section.calibration`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.` `{solo: true}`, refused for a section the Edit panel has not got; header clicks run `section.` too, Calibration included). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | +| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `cmd:section.calibration`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests/solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.` `{solo: true}`, refused for a section the Edit panel has not got; header clicks run `section.` too, Calibration included). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | | LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | | LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | @@ -249,11 +249,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A, its own row in Keyboard Shortcuts: `crop.lockAspect` toggles it, sharing A with Visualize Spots by context), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests/crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests/crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required); turns by at most 10° (`STRAIGHTEN_MAX_DEGREES`) and only on at least 3 agreeing lines (`STRAIGHTEN_MIN_LINES`), and says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 68 photos straightened by hand in Lightroom Classic and 300 left as shot: it levels 14 of the 68 within 0.5° (1 more than 1° off) and turns 19 of the 300 by more than 1° (before the limits: 79, some by 20° or more). 🟡 because it still leaves most tilted photos alone: a soft sea horizon among wave crests, and verticals that converge in perspective, aren't used yet | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | -| LR-GUIDED-SELECT | Guide selection in Guided Upright mode | P0 | ✅ | `cmd:tool.guidedUpright`, `cmd:geometry.guides`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/state.rs` (`selected_guide`), `crates/ui-egui/src/tests_guided_guides.rs` | a press near a guide's line selects it (`selected_guide = Some((photo id, index))`; another press on it deselects), a press on an empty spot starts a new guide at that point, and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step; clicking an endpoint only selects); tested in `tests_guided_guides` 8/8 | -| LR-GUIDED-DELETE | Delete removes the selected guide alone | P0 | ✅ | `cmd:photo.delete`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_guided_guides.rs` | with the Guided Upright tool open (`RightPanel::Crop && tool == "guidedUpright"`), Delete / Backspace removes only the selected guide and clears the selection; with nothing selected it removes nothing (the photo itself is never deleted while the tool is open); tested in `tests_guided_guides` | +| LR-GUIDED-SELECT | Guide selection in Guided Upright mode | P0 | ✅ | `cmd:tool.guidedUpright`, `cmd:geometry.guides`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/state.rs` (`selected_guide`), `crates/ui-egui/src/tests/guided_guides.rs` | a press near a guide's line selects it (`selected_guide = Some((photo id, index))`; another press on it deselects), a press on an empty spot starts a new guide at that point, and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step; clicking an endpoint only selects); tested in `tests_guided_guides` 8/8 | +| LR-GUIDED-DELETE | Delete removes the selected guide alone | P0 | ✅ | `cmd:photo.delete`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests/guided_guides.rs` | with the Guided Upright tool open (`RightPanel::Crop && tool == "guidedUpright"`), Delete / Backspace removes only the selected guide and clears the selection; with nothing selected it removes nothing (the photo itself is never deleted while the tool is open); tested in `tests_guided_guides` | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match | | LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays | @@ -293,7 +293,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) | -| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | +| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | @@ -308,9 +308,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | | LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | -| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests/preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | -| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests_tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests/tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | @@ -321,7 +321,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; the preset applied to a photo stays marked on returning to it until the photo is edited further (issue #2392); resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | | LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | -| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); starred presets are listed in a Favorites section on top, as in the Profile browser (issue #1999, `crates/ui-egui/src/tests_preset_favorites.rs`); no hiding of groups | +| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); starred presets are listed in a Favorites section on top, as in the Profile browser (issue #1999, `crates/ui-egui/src/tests/preset_favorites.rs`); no hiding of groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | | LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | | LR-PRE-PREMIUM | Built-in presets (own) | P2 | ✅ | `crates/engine/src/presets.rs` | 41 own-authored presets in 10 groups (Color, Film, B&W incl. toners, Portrait, Landscape, Urban, Food, Seasons, Vintage, Style) | @@ -389,24 +389,24 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | -| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | +| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests/export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | +| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | -| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | +| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | -| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | +| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/it/lossy_dng.rs`); no embedded JPEG preview size | -| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | +| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests/export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/it/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | -| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | +| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | | LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). Imported PSD/PSB photos retain embedded capture dates; PSD edit copies retain EXIF/XMP fields in the catalog and All Metadata. Original with Remove Location Info is refused when embedded PSD/PSB GPS is detected. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -429,15 +429,15 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; At launch: open the last library or ask (see LRC-LIB-CATALOG-SWITCH); a damaged settings file is kept as `.corrupt-