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/Cargo.lock b/Cargo.lock index 78de058c3..378669bb9 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", @@ -1042,6 +1108,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 +1131,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 +1144,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 +1163,74 @@ 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=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" [[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=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +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=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +dependencies = [ + "egui", + "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 +1319,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 +1350,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 +1412,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1308,6 +1445,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 +1493,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 +1526,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 +1542,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 +1550,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1420,9 +1562,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 +1581,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 +1600,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 +1638,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 +1685,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 +1732,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 +1741,7 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", @@ -1609,16 +1749,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 +1931,28 @@ 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 = "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 +1982,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 +2499,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 +2545,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 +2585,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 +2694,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 +2728,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -2551,7 +2739,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 +2787,22 @@ 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 = "image" version = "0.25.10" @@ -2885,7 +3089,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 +3121,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 +3216,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 +3247,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 +3291,10 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", + "craft-update", "eframe", "egui", "lightcraft-codecs", @@ -3100,7 +3305,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 +3318,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "crc32fast", @@ -3131,7 +3336,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3147,7 +3352,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.13.0" +version = "0.14.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3178,7 +3383,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3187,12 +3392,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 +3409,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 +3425,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 +3437,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 +3470,7 @@ dependencies = [ "lightcraft-testkit", "lightcraft-tiff", "log", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "rayon", "serde", "serde_json", @@ -3275,7 +3481,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3290,7 +3496,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.13.0" +version = "0.14.0" dependencies = [ "log", "rustls", @@ -3301,15 +3507,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 +3523,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.13.0" +version = "0.14.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3337,7 +3543,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.13.0" +version = "0.14.0" dependencies = [ "heic-rs", "rayon", @@ -3345,23 +3551,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 +3578,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3388,20 +3594,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 +3626,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.13.0" +version = "0.14.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3432,7 +3638,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.13.0" +version = "0.14.0" dependencies = [ "libc", "lightcraft-color", @@ -3444,7 +3650,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.13.0" +version = "0.14.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3455,7 +3661,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-testkit", "lightcraft-tiff", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "rayon", "serde", @@ -3468,7 +3674,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.13.0" +version = "0.14.0" dependencies = [ "image", "lightcraft-color", @@ -3479,7 +3685,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.13.0" +version = "0.14.0" dependencies = [ "candle-core", "candle-nn", @@ -3493,7 +3699,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.13.0" +version = "0.14.0" dependencies = [ "lightcraft-raster", "serde", @@ -3502,15 +3708,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 +3725,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 +3756,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "crc32fast", "eframe", @@ -3738,18 +3946,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 +4051,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 +4143,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 +4259,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 +4290,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 +4340,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4126,7 +4351,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 +4408,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4194,7 +4419,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 +4456,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 +4481,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 +4526,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 +4586,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 +4621,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 +4734,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 +4757,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 +4767,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 +4780,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 +4868,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 +4926,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 +4947,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 +4967,12 @@ dependencies = [ "portable-atomic", ] +[[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 +5036,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 +5130,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 +5180,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 +5228,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 +5246,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 +5339,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 +5382,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 +5459,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 +5571,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 +5605,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 +5732,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 +5767,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 +5801,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 +5850,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 +5888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5571,7 +5962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", ] [[package]] @@ -5782,6 +6173,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 +6265,36 @@ 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 = "tokio" version = "1.53.1" @@ -5956,6 +6387,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 +6419,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 +6483,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 +6501,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 +6535,41 @@ 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 = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "uuid" version = "1.26.1" @@ -6099,9 +6594,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 +6609,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 +6619,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 +6844,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 +6881,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 +7032,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", @@ -6689,7 +7203,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 +7212,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 +7230,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 +7255,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 +7311,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -6918,21 +7357,55 @@ 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 = "x11-dl" version = "2.21.0" @@ -7009,7 +7482,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.0" +version = "0.14.0" dependencies = [ "brotli", "flate2", @@ -7246,7 +7719,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 +7751,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 +7827,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..f0a5935e6 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 = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", 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-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/web.rs b/apps/lightcraft-web/src/web.rs index 8cdb32462..31fe2cb0a 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(); @@ -231,6 +234,9 @@ fn services(originals: Originals, backend: Option, files: Files, frozen 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| { @@ -273,7 +279,7 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc, boot: Boot, chinese_font: Vec) -> Self { let Boot { opts, backend, files, index, lib_dir, load_error } = boot; let originals = Originals::default(); @@ -472,7 +481,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(); @@ -498,7 +512,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, @@ -517,8 +531,10 @@ 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()); + let dropped = self.app.take_dropped_files(); // a dialog is on screen (the card said so while the files hovered): nothing is read or imported // (files read from an earlier drop still finish below) if !dropped.is_empty() && self.app.ui.dialog.is_some() { @@ -528,8 +544,8 @@ impl WebApp { for f in dropped { let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { - let name = 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())), } @@ -589,7 +605,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(); @@ -679,13 +695,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 @@ -694,26 +711,24 @@ 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. + // A prompt owns the library too: keep taking the queue only after it is gone, so a drop + // during a notice cannot add photos behind it. + let ctx = ui.ctx().clone(); + if !lightcraft_ui_egui::panels::notices::blocks_input(&self.app) { + 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()); @@ -723,6 +738,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/main.rs b/apps/lightcraft/src/main.rs index 2193666fd..868112a97 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -75,8 +75,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); @@ -259,13 +261,15 @@ fn load_prefs(in_memory: bool) -> (Option, Option, bool) { load_prefs_at(&path, !in_memory) } -/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. +/// 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)) => workspace.validate().is_err(), + Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), Ok(None) => false, Err(_) => true, }); @@ -376,7 +380,7 @@ impl PrefsWriter { } let Some(path) = self.path.clone() else { return Ok(()) }; if let Some(workspace) = &ui.docking { - workspace.validate()?; + lightcraft_ui_egui::docking::validate(workspace)?; } let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; if bytes == self.written { @@ -404,15 +408,26 @@ impl PrefsWriter { } } + /// 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. 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))); + // 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`) + // 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(); - app.ui.window = self.window.note(remembered, &seen, at); + 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) @@ -727,6 +742,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> // 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() })), @@ -867,6 +883,11 @@ fn panic_log_path_with(env: impl Fn(&str) -> Option, temp: s } 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())); @@ -995,6 +1016,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; } @@ -1263,7 +1285,7 @@ mod tests { 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, + 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(); 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/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/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/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/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/export.rs b/crates/engine/src/export.rs index b1a5bce0f..236387b4d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -2026,6 +2026,18 @@ 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); 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/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/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/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/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 5ce439681..ba7e1c63f 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.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/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_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..ac0a8808a 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); } @@ -1171,4 +1196,32 @@ mod tests { 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/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/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/opcodes.rs b/crates/raw/src/opcodes.rs index 6cf224984..d5a3ffd1e 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) { @@ -765,6 +785,27 @@ pub(crate) mod tests { 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); diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 95f9bdcaa..4bcf6963e 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); @@ -397,8 +392,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 +412,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); @@ -440,6 +446,52 @@ mod tests { 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. 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/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/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/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..166c0e9b6 100644 --- a/crates/ui-egui/examples/thumb_request_bench.rs +++ b/crates/ui-egui/examples/thumb_request_bench.rs @@ -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/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 835dbcce8..1c59bcae8 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -211,8 +211,8 @@ 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(); @@ -232,7 +232,7 @@ impl<'a> AlbumPicker<'a> { } 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); + register(ui, format!("albumPickerSearch:{id}"), search_box.rect); if state.focus { search_box.request_focus(); state.focus = false; @@ -252,7 +252,7 @@ impl<'a> AlbumPicker<'a> { 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); + 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, @@ -289,10 +289,10 @@ impl<'a> AlbumPicker<'a> { egui::TextStyle::Button.resolve(ui.style()), t.text_label, ); - register(ui.ctx(), format!("albumPickerFolder:{}:{id}", e.id), r.rect); + 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(); - r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, &name)); + 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; diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs index 4e23f1c16..d3fa98821 100644 --- a/crates/ui-egui/src/button.rs +++ b/crates/ui-egui/src/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/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/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/credits.rs b/crates/ui-egui/src/credits.rs index d451aa79d..578616068 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/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/date_picker.rs b/crates/ui-egui/src/date_picker.rs index c84df98c4..eda461eb7 100644 --- a/crates/ui-egui/src/date_picker.rs +++ b/crates/ui-egui/src/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/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..8be75ee88 100644 --- a/crates/ui-egui/src/drop_overlay.rs +++ b/crates/ui-egui/src/drop_overlay.rs @@ -69,6 +69,11 @@ 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 Foreground window. While a prompt + // owns input the drag can only be background noise: do not paint over or register it. + if crate::panels::notices::blocks_input(app) { + return; + } let count = ctx.input(|i| i.raw.hovered_files.len()); if count == 0 { return; @@ -79,7 +84,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 +99,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 8686dbdf5..ebb14e03a 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() } @@ -599,6 +638,7 @@ mod tests { } 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. @@ -630,7 +670,9 @@ mod tests { 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]); + // 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), @@ -639,6 +681,9 @@ mod tests { ([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), ] { @@ -851,7 +896,7 @@ mod tests { 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") + 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); @@ -1524,7 +1569,7 @@ mod tests { 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)))); + 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(); @@ -1552,7 +1597,7 @@ mod tests { 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.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!( @@ -2856,17 +2901,36 @@ mod tests { 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]); + // (tall enough for the place fields to show without scrolling) + let mut h = demo([1300.0, 1200.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}"); + 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(); @@ -2964,13 +3028,13 @@ mod tests { assert!(h.app.ui.settings.info_selected_photos); // a mixed field left untouched saves nothing let undo = h.app.session.undo.len(); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + 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] { - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + 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); @@ -2978,7 +3042,7 @@ mod tests { } // the mixed title starts empty; the shared creator keeps its value for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] { - ask(&mut h, "ui.clickWidget", json!({"id": format!("field:{key}")})); + 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"})); @@ -2990,7 +3054,7 @@ mod tests { 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); - ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + 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"})); @@ -3049,7 +3113,7 @@ mod tests { h.request("ui.set", json!({"view": "detail", "right": "info"}), t); exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); h.settle(SETTLE); - ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + 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"})); @@ -3080,7 +3144,7 @@ mod tests { exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); h.settle(SETTLE); - ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + 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"})); @@ -3151,7 +3215,7 @@ mod tests { 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); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + 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"})); @@ -3181,7 +3245,7 @@ mod tests { 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); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + 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]]})); @@ -3191,7 +3255,7 @@ mod tests { // 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); - ask(&mut h, "ui.clickWidget", json!({"id": "field:gps"})); + 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"})); @@ -3202,7 +3266,7 @@ mod tests { exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); h.settle(SETTLE); let before = meta(&h, 0).gps; - ask(&mut h, "ui.clickWidget", json!({"id": "field: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"})); @@ -4558,7 +4622,7 @@ mod tests { // 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 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); @@ -4595,7 +4659,7 @@ mod tests { 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"))); + 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); @@ -5071,6 +5135,7 @@ mod tests { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(10.0, -10.0), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, modifiers: egui::Modifiers::NONE, }), None, @@ -5149,7 +5214,13 @@ mod tests { ] { 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 }); + 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(); } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index d62311c56..74dcc6c40 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -47,7 +47,7 @@ pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { 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)); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y)); } } @@ -1106,7 +1106,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { // 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)); + 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); @@ -1124,7 +1124,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } }); if !ui.is_sizing_pass() { - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(GRID_BOTTOM), grid.inner_rect.bottom())); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom())); } ui.add_space(4.0); // options @@ -1138,7 +1138,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .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); + 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).")) @@ -1184,7 +1184,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) .small(), ); - register(ui.ctx(), "label:importModeHelp", r.rect); + 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; @@ -1212,7 +1212,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { 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); + 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() { @@ -1221,15 +1221,16 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } } }); - register(ui.ctx(), "combo:importOrganize", combo.response.rect); + 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::new("import-folder-template"); + 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.ctx(), "field:importFolderTemplate", r.rect); + register(ui, "field:importFolderTemplate", r.rect); tag_toggle(ui, "importFolders") }); if tags_open { @@ -1253,10 +1254,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { 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); + register(ui, "check:importDng", r.rect); }); } - let rename_id = egui::Id::new("import-rename"); + 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); @@ -1266,7 +1267,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}")) .desired_width(w), ); - register(ui.ctx(), "field:importRename", r.rect); + register(ui, "field:importRename", r.rect); tag_toggle(ui, "importRename") }); if tags_open { @@ -1276,7 +1277,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { 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); + 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) @@ -1292,13 +1293,14 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { _ => "Leave them", }; let mut pick = None; - crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + 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); } @@ -1324,12 +1326,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .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); + 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.ctx(), "importAlbum:new", new.rect); + register(ui, "importAlbum:new", new.rect); if new.clicked() { extra = Some(true); } @@ -1350,7 +1352,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } 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); + register(ui, "field:importAlbumName", r.rect); } }); }); @@ -1362,7 +1364,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .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| { + 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(); } @@ -1371,11 +1373,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { 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() }; - crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { + 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(); } @@ -1385,22 +1388,27 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } } }); + 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.ctx(), "field:importKeywords", r.rect); + 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::new(("import-cell", i)), Sense::click()); + let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click()); crate::widgets::announce(&resp, c.name.clone()); - register(ui.ctx(), format!("import:{i}"), img); + 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) @@ -1481,7 +1489,7 @@ pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { /// 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 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")); @@ -1547,7 +1555,7 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) }, ); - register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect); + register(ui, format!("button:{key}Tag-{i}"), r.rect); if r.clicked() { insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fc43172c..e53989fca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,7 +49,7 @@ pub mod sync; pub mod tasks; pub mod text_field; pub mod theme; -pub mod titlebar; +pub mod updates_ui; pub mod widgets; pub mod window_place; @@ -140,6 +140,8 @@ mod tests_layout_image; #[cfg(test)] mod tests_library_problem; #[cfg(test)] +mod tests_lifecycle_037; +#[cfg(test)] mod tests_lightroom_destination; #[cfg(test)] mod tests_lights_out; @@ -305,6 +307,10 @@ pub struct Services { /// 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). @@ -388,6 +394,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 @@ -403,17 +411,61 @@ 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, + /// 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, @@ -423,10 +475,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. @@ -442,8 +499,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). @@ -523,6 +586,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, @@ -534,6 +599,8 @@ pub struct LightcraftApp { } 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; @@ -545,25 +612,45 @@ impl LightcraftApp { 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![], + 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, @@ -574,6 +661,9 @@ impl LightcraftApp { 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, @@ -611,6 +701,7 @@ impl LightcraftApp { 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, @@ -626,6 +717,8 @@ impl LightcraftApp { 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 @@ -633,6 +726,110 @@ impl LightcraftApp { 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 + } + + /// 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. + fn shot_document_generation(&self) -> u64 { + self.session.library_generation() + } + + /// 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 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. + 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. + 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); + } + /// 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(); @@ -715,8 +912,7 @@ impl LightcraftApp { _ => 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); + let now = self.tick_now(); self.ui.toast = Some((message, now + TOAST_SECONDS, None)); } } @@ -730,8 +926,7 @@ impl LightcraftApp { } 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); + let now = self.tick_now(); self.ui.toast = Some((text, now + TOAST_SECONDS, label)); } match &r { @@ -801,10 +996,34 @@ impl LightcraftApp { 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); + /// 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. @@ -842,7 +1061,7 @@ impl LightcraftApp { match (unsaved, self.ui.unsaved_seen) { (Some((n, e)), false) => { self.ui.unsaved_seen = true; - let t = ctx.input(|i| i.time); + 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", @@ -880,8 +1099,8 @@ impl LightcraftApp { } /// 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); + 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)); } @@ -900,9 +1119,8 @@ impl LightcraftApp { /// 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) { + fn ai_mask_detail(&mut self, ctx: &egui::Context, now: f64) { let polled = self.session.segment_poll(); - let now = ctx.input(|i| i.time); if polled.changed { ctx.request_repaint(); } @@ -968,11 +1186,14 @@ impl LightcraftApp { } /// 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); + 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)); } + /// 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. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -986,68 +1207,540 @@ impl LightcraftApp { 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, - }); + 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); } - /// 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. + /// 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`]). + 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. + 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`]). + 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. + 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. + 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. + 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. + 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. + fn queue_pointer_cleanup(&mut self) { + self.pointer_cleanup = true; + } + + /// Apply a queued pointer cancellation before egui processes the next input pass. + 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. + 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)})); + } + } + + /// 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. + 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(()) + } + + /// 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. fn issue_screenshots(&mut self, ctx: &egui::Context) { if self.pending_screenshots.is_empty() { return; } - let now = now_ms(); + 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; - shots.retain_mut(|s| { - if let Some(sent) = s.sent_at { + 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 { - return true; + 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"); - s.sent_at = None; - s.headless = true; - s.deadline = now + SCREENSHOT_SETTLE_MS; + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + shot.sent_at_ms = None; + shot.issued = None; + shot.headless = true; } - s.frames += 1; - let ready = now >= s.not_before && s.frames >= 3 && (!busy || now > s.deadline); - if s.headless { + // 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 ready || !shadow_ticked { self.headless_screenshot(ctx, ready) } else { None }; + let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) }; shadow_ticked = true; if let Some(img) = img { - let _ = s.reply.send(control::save_screenshot(self, &img, s.path.as_deref())); - return false; + let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + match bytes { + Some(bytes) if bytes > shot.reserved => { + // A stale/undersized reservation is not a user error: grow it to the + // actual image and encode it when the per-image and aggregate caps allow. + 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; + } + Some(_) => {} + None => { + 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; + } + } + let _ = shot.reply.send(control::save_screenshot(self, &img, shot.target.as_ref())); + self.release_shot_bytes(shot.reserved); + continue; } - true - } else { - if ready { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token))); - s.sent_at = Some(now); - } - true + keep.push(shot); + continue; } - }); - shots.append(&mut self.pending_screenshots); - self.pending_screenshots = shots; + // 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)); } @@ -1086,7 +1779,7 @@ impl LightcraftApp { // 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)); + 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) { @@ -1098,29 +1791,76 @@ impl LightcraftApp { img } - fn collect_screenshots(&mut self, ctx: &egui::Context) { + /// 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. + 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())) + 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; } - _ => 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())); + 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; } /// The process is about to end (settings and library are saved by then): make sure nothing is @@ -1140,19 +1880,38 @@ impl LightcraftApp { stopped && idle } - /// Per-frame logic before layout (control channel, renders, shortcuts, drops). + /// 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); - let t0 = now_ms(); - panels::library_problem::logic(self, ctx); - self.logic_inner(ctx); - self.perf.logic_ms = now_ms() - t0; + // 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; } - fn logic_inner(&mut self, ctx: &egui::Context) { + /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. + 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()); @@ -1170,25 +1929,32 @@ impl LightcraftApp { } else { self.fonts_ready = true; } - self.sync_theme(ctx); + } + + /// 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. + 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); } - // (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); - } + let now = self.tick_now(); + self.drain_control(ctx); self.apply_settings(ctx); - if !self.synthetic.is_empty() { + if self.queued_input_needs_repaint() { ctx.request_repaint(); } self.renderer.poll(ctx, &mut self.session); @@ -1196,31 +1962,30 @@ impl LightcraftApp { import::poll_scan(self, ctx); sync::poll(self, ctx); sync::poll_run(self, ctx); - import::tick(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.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; + 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"; - 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() @@ -1257,7 +2022,6 @@ impl LightcraftApp { #[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(); @@ -1290,25 +2054,111 @@ impl LightcraftApp { 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.expire_gestures(); self.collect_screenshots(ctx); self.issue_screenshots(ctx); - if self.fonts_ready { + 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`. + 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. + 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. + 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 owns the library too: files dropped while it is up wait in the queue and are + // imported on the first UI pass after it goes away, never behind it. + if !panels::notices::blocks_input(self) { + 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); } + } + + /// 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 { + std::mem::take(&mut self.dropped_files) + } + + /// Import native drops as presets, face models or photos. + fn import_dropped_files(&mut self, ctx: &egui::Context) { + let dropped = self.take_dropped_files(); + if dropped.is_empty() { + return; + } #[cfg(not(target_arch = "wasm32"))] - self.handle_dropped_files(ctx); + 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) { + fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec) { 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()); + 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; } @@ -1355,6 +2205,28 @@ impl LightcraftApp { } } + /// 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); + } + } + + /// 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. + 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. fn apply_settings(&mut self, ctx: &egui::Context) { if self.gpu_applied != Some(self.ui.settings.gpu) { @@ -1409,8 +2281,8 @@ impl LightcraftApp { /// 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); + 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; } @@ -1437,13 +2309,13 @@ impl LightcraftApp { 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)); - } + /// 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) @@ -1454,6 +2326,53 @@ impl LightcraftApp { 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; } @@ -1461,7 +2380,9 @@ impl LightcraftApp { 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() { + // 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 @@ -1479,7 +2400,49 @@ impl LightcraftApp { } 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; } /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / @@ -1492,7 +2455,7 @@ impl LightcraftApp { 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 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); @@ -1521,7 +2484,7 @@ impl LightcraftApp { } } }); - self.perf.frame_ms = now_ms() - t0; + 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); } @@ -1567,7 +2530,8 @@ impl LightcraftApp { } } - /// Lay out the whole window. + /// 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(); @@ -1575,28 +2539,109 @@ impl LightcraftApp { ctx.request_repaint(); return; } + self.sync_theme(&ctx); + self.ui_pass(&ctx); + self.draw_window(ui); + self.finish_ui_pass(&ctx); + } + + /// 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. + 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. + 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); - let t0 = now_ms(); + // 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; - 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); + if notice_blocks { + panels::notices::restore_prompt_pointer(self, &ctx); + } panels::notices::show(self, &ctx); - panels::dialogs::show(self, &ctx); - panels::library_problem::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); - self.widgets = widgets::take_registry(&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; } @@ -1610,9 +2655,16 @@ impl LightcraftApp { 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); - panels::dialogs::show(self, &ctx); - panels::library_problem::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); @@ -1624,31 +2676,237 @@ impl LightcraftApp { panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); drop_overlay::show(self, &ctx); - self.widgets = widgets::take_registry(&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); } } -/// 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"), + } + } +} + +/// 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). +fn import_tick_allowed(ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 +} + +/// 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. +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) +} + +/// 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)] +struct GestureFrame { + view: crate::state::ViewMode, + active: Option, + library: u64, + dialog: Option>, + left_panel: bool, + right: crate::state::RightPanel, + presets: bool, + filmstrip: bool, +} + +/// 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 pointer press delivered to the UI, kept so a canceled gesture knows that a button is down. +#[derive(Clone, Copy, Debug)] +struct InjectedPress { + button: egui::PointerButton, +} /// 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, + 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, } -/// Wall-clock milliseconds since the Unix epoch (`web-time` maps to `Date.now()` on the web). +/// 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)] +struct AppClock { + start: web_time::Instant, + simulated: Option, +} + +impl AppClock { + /// Start the clock at the current monotonic instant with no simulated time. + 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`. + 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/lightroom_destination.rs b/crates/ui-egui/src/lightroom_destination.rs index a62c59825..5fd781cfd 100644 --- a/crates/ui-egui/src/lightroom_destination.rs +++ b/crates/ui-egui/src/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/menu_level.rs b/crates/ui-egui/src/menu_level.rs index 96ff422f4..611812901 100644 --- a/crates/ui-egui/src/menu_level.rs +++ b/crates/ui-egui/src/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/menubar.rs index 30a3b474b..ba4a8a7c2 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/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)); @@ -1841,6 +1890,73 @@ mod tests { 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"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8c249dd36..1ed6dbbf1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -263,6 +263,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ // 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 @@ -667,8 +668,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option { // the photos in view, full screen, with the playback settings (see panels::slideshow) - let now = ctx.input(|i| i.time); + 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")); @@ -700,7 +702,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let now = ctx.input(|i| i.time); + let now = app.tick_now(); crate::panels::slideshow::command(app, id, now) } "view.slideshowOptions" => { @@ -708,11 +710,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.fullscreen = !app.ui.fullscreen; - if !app.ui.fullscreen { - app.ui.slideshow = None; - } 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(); @@ -890,7 +893,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option RightPanel::Edit, p => p, @@ -903,6 +906,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -1585,6 +1590,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), "photo.editInExternal" => edit_in(app, &ctx, p, false), "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index 13aad67a6..4dc744b6d 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -428,7 +428,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/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/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 c77c96f42..5e610b098 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -12,7 +12,7 @@ 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}; /// An in-progress on-canvas gesture. #[derive(Clone, Debug)] @@ -232,7 +232,7 @@ pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: 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"); + 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)); } @@ -345,13 +345,13 @@ pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_ 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); + register_geometry(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 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))); @@ -498,6 +498,10 @@ fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &Window 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(); @@ -568,7 +572,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // 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); @@ -881,7 +885,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 { @@ -895,7 +899,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 { @@ -984,7 +988,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 @@ -1051,7 +1055,7 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li 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))); + 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), @@ -1135,7 +1139,7 @@ fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_ca 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))); + register_geometry(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); at.y + OVERLAY_BADGES_H } @@ -1183,7 +1187,7 @@ fn region_overlay( 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 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() { @@ -1196,7 +1200,7 @@ fn region_overlay( 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()); + 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); @@ -1241,9 +1245,9 @@ fn region_overlay( } 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()); + let resp = ui.interact(xr, egui::Id::unique(("region-x", index)), Sense::click()); crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); - register(ui.ctx(), format!("regionRemove:{index}"), xr); + 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); @@ -1291,9 +1295,9 @@ fn region_overlay( 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()); + 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.ctx(), format!("regionLabel:{index}"), label); + 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 }); @@ -1338,9 +1342,9 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { 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()); + let resp = ui.interact(r, egui::Id::unique("loupe-filter-pill"), Sense::click()); crate::widgets::announce(&resp, crate::i18n::tr("Filters")); - register(ui.ctx(), "loupe:filters", r); + 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)); @@ -1365,8 +1369,8 @@ fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { 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)); + 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 @@ -1436,8 +1440,8 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect 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()); + 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() @@ -1498,7 +1502,7 @@ pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Opt 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); + register(ui, id, r); r } @@ -1519,8 +1523,8 @@ pub(crate) fn failed_pill( 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}"))); + 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}"))); } fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { @@ -1744,7 +1748,7 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons // 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))); + 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); @@ -1950,7 +1954,7 @@ pub(crate) fn readout_rect(at: Pos2, size: egui::Vec2, bounds: Rect) -> Rect { /// 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))) + 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, @@ -1960,7 +1964,7 @@ fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) { 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); + register(ui, "cropRotateCursor", r); } /// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges @@ -1994,7 +1998,7 @@ fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { 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); + register(ui, "cropAngleReadout", rect); } /// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are @@ -2027,7 +2031,7 @@ fn guided_overlay( }; 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)); + 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); @@ -2376,7 +2380,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons 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))); + 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)); } @@ -2386,7 +2390,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons 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))); + 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)); } } @@ -2401,7 +2405,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons 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))); + 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)); } } @@ -2414,7 +2418,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons { 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))); + register(ui, format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); grips.push((m.id, ci, 0, q)); } } @@ -2462,7 +2466,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons 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)); + app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); } } Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), @@ -2697,11 +2701,11 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo } 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))); + 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.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); + 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) { @@ -2812,7 +2816,7 @@ fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &De } } // 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 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( @@ -2912,6 +2916,11 @@ 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); @@ -2929,10 +2938,10 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { // 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); + let follow = super::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.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); + register(ui, "filmstrip", child.max_rect()); + let wheel = super::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()); @@ -2949,10 +2958,10 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { 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 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.ctx(), format!("film:{}", id.0), cr); + 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(); @@ -3003,13 +3012,13 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { 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 { + 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.ctx(), format!("filmstack:{}", id.0), br); + 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); @@ -3408,7 +3417,7 @@ 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 } + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } } #[test] diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f14d7338b..f26b43325 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -28,7 +28,7 @@ pub(crate) fn rename_preview( 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 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 @@ -82,7 +82,7 @@ fn watermark_preview( (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_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, _ => { @@ -169,7 +169,7 @@ fn watermark_preview( 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); + crate::widgets::register(ui, "image:exportWatermarkPreview", r.rect); } /// About dialog tabs: (widget id suffix, label). The credits come from `crate::credits`. @@ -181,7 +181,7 @@ 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 +200,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 +229,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 +340,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 +371,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 +387,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 +397,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 +453,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 +496,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 +550,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 +616,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 +631,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 +710,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 +759,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 +789,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 +821,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 +979,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 +1055,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 +1113,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 +1174,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 +1194,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 +1209,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 +1219,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 +1243,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 +1305,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 +1340,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 +1512,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 +1530,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 +1608,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) }; } @@ -1603,10 +1626,10 @@ const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; /// 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 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::new(BACKDROP_SHOWN))).is_some_and(|f| f + 1 >= frame); + 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; @@ -1640,11 +1663,11 @@ const DIALOG_MIN_BODY: f32 = 80.0; const DIALOG_MIN_LIST: f32 = 120.0; fn list_shrink_id() -> egui::Id { - egui::Id::new("dialog-list-shrink") + egui::Id::unique("dialog-list-shrink") } fn list_applied_id() -> egui::Id { - egui::Id::new("dialog-list-applied") + 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 @@ -1828,7 +1851,7 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str .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); + crate::widgets::register(ui, "link:samLicense", r.rect); if r.clicked() { let _ = crate::links::open(app, LICENSE_URL); } @@ -1838,7 +1861,7 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str 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); + crate::widgets::register(ui, "button:samCancel", r.rect); if r.clicked() { app.session.segmenter.cancel_download(); } @@ -1858,7 +1881,7 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str 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); + crate::widgets::register(ui, "button:samFolder", r.rect); if r.clicked() && let Err(e) = app.run("app.openSamModelFolder", json!({})) { @@ -1866,7 +1889,7 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str } } 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); + crate::widgets::register(ui, "link:samHelp", r.rect); if r.clicked() && let Err(e) = crate::links::open(app, SAM_HELP) { @@ -2142,7 +2165,7 @@ fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec) { 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); + crate::widgets::register_response(format!("{tag}:{key}"), r.rect, &r); if r.changed() { if on { groups.push(key); @@ -2154,12 +2177,12 @@ fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec) { }); ui.horizontal(|ui| { let all = ui.small_button(crate::i18n::tr("All")); - crate::widgets::register(ui.ctx(), format!("{tag}:all"), all.rect); + 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.ctx(), format!("{tag}:none"), none.rect); + crate::widgets::register(ui, format!("{tag}:none"), none.rect); if none.clicked() { groups.clear(); } @@ -2196,7 +2219,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex (R::Percent, "Percentage"), ]; let r_full = ui.checkbox(full, crate::i18n::tr("Full size")); - crate::widgets::register(ui.ctx(), "check:exportFullSize", r_full.rect); + 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); @@ -2206,7 +2229,8 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); } }); - crate::widgets::register(ui.ctx(), "combo:exportResizeMode", c.response.rect); + 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 { @@ -2237,7 +2261,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex } } let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge")); - crate::widgets::register(ui.ctx(), "check:exportDontEnlarge", c.rect); + crate::widgets::register(ui, "check:exportDontEnlarge", c.rect); } let mut p = *ppi as f64; if num(ui, &PPI, &mut p) { @@ -2307,12 +2331,12 @@ fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: & } 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); + 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.ctx(), "check:slideshowExportFullScreen", r.rect); + 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.", @@ -2418,8 +2442,10 @@ fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) - 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())) { + // 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) @@ -2428,7 +2454,7 @@ fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) /// 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 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)); diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 2063580ae..10542e443 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -199,7 +199,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"})); } @@ -447,7 +447,7 @@ fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSett 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); + register(ui, format!("profileMenu:{key}:{id}"), r.rect); if r.clicked() { pick.0 = Some(id.to_string()); } @@ -478,7 +478,7 @@ fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSett } }); }); - register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + 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); } @@ -497,7 +497,7 @@ fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSett } }); }); - register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + 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); } @@ -511,13 +511,13 @@ fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSett 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); + 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.ctx(), "profileMenu:browse", r.rect); + register(ui, "profileMenu:browse", r.rect); if r.clicked() { let _ = app.run("panel.profiles", json!({})); } @@ -565,7 +565,7 @@ fn section( 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); + 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); @@ -596,7 +596,7 @@ fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option) -> Option { - let key = egui::Id::new(("hdrHistogram", id)); + 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())); @@ -633,7 +633,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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); + 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)); @@ -664,7 +664,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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( + 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.", )); } @@ -736,9 +736,9 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("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))); + 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. @@ -784,7 +784,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } // 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); + 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; @@ -821,12 +821,12 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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); + 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.ctx(), "button:proofDisplayWarning", r2.rect); + 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})); } @@ -838,7 +838,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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| { + 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})); @@ -851,6 +851,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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})) @@ -864,7 +865,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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); + 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()})); } @@ -872,13 +873,13 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); 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); + 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.ctx(), "button:createProofCopy", r.rect); + 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); @@ -978,8 +979,8 @@ fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui:: 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"); + 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(); @@ -1061,7 +1062,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev ] { 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); + 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 }); @@ -1099,9 +1100,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev 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()); + 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.ctx(), "curve", r); + 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); @@ -1164,7 +1165,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev 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)); + 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)); @@ -1298,7 +1299,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) 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); + register(ui, format!("curvePreset:{}", p.name), r.rect); if r.clicked() { let _ = app.run("curve.applyPreset", json!({"name": p.name})); ui.close(); @@ -1315,7 +1316,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.separator(); let shaped = current.as_deref() != Some("Linear"); let r = ui.add_enabled(shaped, egui::Button::new(crate::i18n::tr("Save Point Curve…"))); - register(ui.ctx(), "curvePresetMenu:save", r.rect); + 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(); @@ -1353,13 +1354,13 @@ fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { crate::i18n::tr_format!("Reset {} Channel", crate::i18n::tr(channel_label(ch))) }; let r = ui.button(label); - register(ui.ctx(), "curveMenu:resetChannel", r.rect); + 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.ctx(), "curveMenu:resetAll", r.rect); + register(ui, "curveMenu:resetAll", r.rect); if r.clicked() { let _ = app.run("curve.reset", json!({"channel": "all"})); ui.close(); @@ -1408,7 +1409,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { 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); + 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)); @@ -1500,7 +1501,7 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) 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); + 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); @@ -1551,7 +1552,7 @@ fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { 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); + 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; } @@ -1634,12 +1635,12 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings // 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()); + 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.ctx(), format!("wheel:{key}"), r); + 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)); @@ -1658,7 +1659,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings // 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 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 { @@ -1710,7 +1711,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings } // 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))) + && 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); @@ -1732,9 +1733,9 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings // 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()); + let er = ui.interact(eye, egui::Id::unique(("wheelEye", key)), Sense::click()); crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); - register(ui.ctx(), format!("wheelEye:{key}"), eye); + 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() { @@ -1856,7 +1857,7 @@ fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option { return Some(q); } let ctx = ui.ctx(); - let id = egui::Id::new(WHEEL_CAPTURE); + 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") { @@ -1899,7 +1900,7 @@ fn give_pointer_back(ctx: &egui::Context, origin: Pos2) { /// 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 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)); @@ -2006,12 +2007,12 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { let r = crate::widgets::arrow_button(ui, "", icon, false); r.widget_info(|| { egui::WidgetInfo::labeled( - egui::WidgetType::Button, + 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.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect); + 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() { diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 902c0fd84..76cd1295c 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -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/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..ba2e48869 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -309,10 +309,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 +384,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 +393,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 +656,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 { @@ -758,8 +758,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 +775,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 +900,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 +962,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 +970,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 +979,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 +991,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 +1042,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 +1064,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 +1080,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 +1144,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 +1297,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 +1438,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/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/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index 1dc7ffbb9..3ebe25057 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/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,6 +82,9 @@ 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) { // 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()); @@ -103,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})); } @@ -118,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})); } 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..5a81edfab 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -15,7 +15,7 @@ use crate::widgets::{icon_button, register}; /// 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) + 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. @@ -35,7 +35,7 @@ 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); + let m = d.get_temp_mut_or_insert_with::(egui::Id::unique("left-content-width-next"), || 0.0); *m = m.max(w); }); } @@ -56,7 +56,7 @@ 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) + 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. @@ -68,7 +68,7 @@ fn indent_x(ctx: &egui::Context, indent: f32) -> f32 { /// 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); + 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) @@ -138,22 +138,22 @@ fn row_sensed( if scroll_asked(ui, id) { ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); } - register(ui.ctx(), format!("source:{id}"), r); + 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::WidgetType::SelectableLabel, true, selected, &name)); + 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::new("left-visible-right"))).unwrap_or(f32::MAX); + 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.ctx(), format!("highlight:{id}"), inner); + register(ui, format!("highlight:{id}"), inner); } else if resp.hovered() { ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6)); } @@ -174,7 +174,7 @@ fn row_sensed( 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); + 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; @@ -182,12 +182,12 @@ fn row_sensed( 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); + 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.ctx(), format!("count:{id}"), rect); + register(ui, format!("count:{id}"), rect); ui.painter().galley(rect.min, galley, t.text_dim); } resp @@ -204,7 +204,7 @@ fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, c return false; } ui.allocate_space(r.size()); - register(ui.ctx(), format!("source:{id}"), r); + register(ui, format!("source:{id}"), r); let indent = drawn_indent(ui, indent); let t = Tokens::get(ui.ctx()); let painter = ui.painter(); @@ -216,7 +216,7 @@ fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, c /// 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()) + 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 @@ -234,7 +234,7 @@ fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open 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); + 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 @@ -260,7 +260,7 @@ fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui:: 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.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); tri } @@ -286,7 +286,7 @@ fn section( 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"); + let show = egui::Id::unique("left-show-section"); if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show)) && asked == section { @@ -294,7 +294,7 @@ fn section( // 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 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()); } @@ -313,7 +313,7 @@ fn section( /// 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) + 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 @@ -327,7 +327,7 @@ fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSec 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)); + 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(); @@ -338,7 +338,7 @@ fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSec // 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.data_mut(|d| d.insert_temp(egui::Id::unique("left-show-section"), (p.section, frame))); ui.ctx().request_repaint(); } } @@ -348,7 +348,7 @@ fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSec 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); + 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); @@ -416,7 +416,7 @@ fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSect 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); + register(ui, "sidebarSectionDrop", line); } } @@ -432,7 +432,7 @@ pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { 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( + 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| { @@ -452,8 +452,8 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe 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 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); @@ -463,12 +463,12 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe 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)); + 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); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); + 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(); @@ -477,7 +477,7 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe 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))); + 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); @@ -488,7 +488,7 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe // 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); + register(ui, format!("{widget}Count:{id}"), count); } if section == SidebarSection::Albums { top_level_drop_target(app, ui, r); @@ -577,13 +577,13 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option