M0.37: port LightCraft UI, input and capture lifecycle to egui 0.37

This commit is contained in:
skylartaylor
2026-10-24 08:02:12 +02:00
parent 98702b5c44
commit 50df646183
89 changed files with 4794 additions and 762 deletions
+1 -1
View File
@@ -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: ""
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
Generated
+190 -88
View File
@@ -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",
@@ -296,6 +291,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1089,7 +1085,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa"
dependencies = [
"egui",
"serde",
@@ -1350,9 +1346,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
"emath",
@@ -1384,9 +1379,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1409,6 +1403,7 @@ dependencies = [
"pollster 1.0.1",
"profiling",
"raw-window-handle",
"send_wrapper",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -1420,9 +1415,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"accesskit",
"ahash",
@@ -1440,9 +1434,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1460,30 +1453,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"accesskit_winit",
"arboard",
"block2 0.6.2",
"bytemuck",
"egui",
"image",
"log",
"objc2 0.6.4",
"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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
"egui",
@@ -1495,9 +1491,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"dify",
"eframe",
@@ -1543,9 +1538,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
]
@@ -1591,9 +1585,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1609,16 +1602,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[package]]
name = "equator"
@@ -1796,6 +1790,12 @@ 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 +1825,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"
@@ -2379,12 +2385,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",
@@ -2532,6 +2538,15 @@ dependencies = [
"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 +2555,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
"serde",
"serde_core",
]
@@ -2551,7 +2566,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -3012,12 +3027,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]]
@@ -4389,6 +4404,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"
@@ -4492,10 +4517,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",
@@ -4504,9 +4540,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",
@@ -4514,9 +4550,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",
@@ -4527,9 +4563,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",
]
@@ -5429,6 +5465,12 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "seq-macro"
version = "0.3.6"
@@ -5964,6 +6006,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"
@@ -6043,6 +6096,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"
@@ -6055,6 +6114,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"
@@ -6107,9 +6172,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",
@@ -6122,9 +6187,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",
@@ -6868,8 +6933,7 @@ 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=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
dependencies = [
"ahash",
"android-activity",
@@ -6941,6 +7005,24 @@ 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"
@@ -7333,3 +7415,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
+26 -5
View File
@@ -10,7 +10,7 @@ rust-version = "1.90"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
@@ -91,10 +91,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 +172,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 = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
+1
View File
@@ -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()
})),
+4 -3
View File
@@ -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,
@@ -58,9 +58,10 @@ impl Bench {
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<Value> {
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);
+31 -21
View File
@@ -225,6 +225,9 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
pick_slideshow_templates: None,
save_slideshow_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| {
@@ -267,7 +270,7 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bo
log::error!("saving {} failed: {e}", op.name());
files.failed(&ops[i..]);
files.set_error(Some(e));
failed_at.set(perf_now());
failed_at.set(lightcraft_ui_egui::monotonic_ms());
break 'outer;
}
}
@@ -488,7 +491,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,
@@ -506,13 +509,15 @@ 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();
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) => log::warn!("reading dropped {name}: {e}"),
}
@@ -563,7 +568,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();
@@ -652,11 +657,10 @@ impl WebApp {
impl eframe::App for WebApp {
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
@@ -665,19 +669,6 @@ 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) {
@@ -685,7 +676,26 @@ impl eframe::App for WebApp {
}
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.
let ctx = ui.ctx().clone();
self.import_dropped(&ctx);
self.app.ui(ui);
// 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());
+3 -1
View File
@@ -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 }
+12 -3
View File
@@ -405,12 +405,20 @@ impl PrefsWriter {
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)
@@ -707,6 +715,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()
})),
+12
View File
@@ -2019,6 +2019,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);
+228 -9
View File
@@ -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<PathBuf>,
}
#[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<SystemTime>,
}
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<String, Vec<(PathBuf, String)>>,
/// The entries of `by_name` that are symlinks, found on the first check that needs them.
links: std::sync::OnceLock<Vec<(PathBuf, String)>>,
/// 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<Vec<(PathBuf, String)>>,
}
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<WriteTarget, String> {
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);
}
}
+32
View File
@@ -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<Item = [u8; 4]>) -> Option<Self> {
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());
}
}
+5
View File
@@ -30,6 +30,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 }
@@ -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();
+2 -2
View File
@@ -212,7 +212,7 @@ impl<'a> AlbumPicker<'a> {
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()));
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();
@@ -292,7 +292,7 @@ impl<'a> AlbumPicker<'a> {
register(ui.ctx(), format!("albumPickerFolder:{}:{id}", e.id), r.rect);
// what assistive tools announce: the folder, and whether it is open
let name = e.name.clone();
r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, &name));
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;
+2 -2
View File
@@ -322,7 +322,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);
@@ -333,7 +333,7 @@ impl<'a> Button<'a> {
let enabled = self.enabled && ui.is_enabled();
let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() });
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 let Some(w) = &self.widget {
crate::widgets::register(ui.ctx(), w.clone(), rect);
}
+3 -3
View File
@@ -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,7 +49,7 @@ 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);
});
+210 -136
View File
@@ -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<String>, headless: bool },
/// A capture was admitted: `target` pins the destination identity until the write.
Screenshot {
target: Option<lightcraft_engine::originals::WriteTarget>,
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 {
@@ -217,128 +227,13 @@ 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.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() {
@@ -430,12 +325,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()) {
@@ -562,22 +463,195 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
Ok(out)
}
pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, path: Option<&str>) -> 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<Value, String> {
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 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 method == "ui.hoverWidget" {
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);
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 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" => {
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(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(Value::Null)
}
"ui.text" => {
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ctx.request_repaint();
Ok(Value::Null)
}
"ui.clipboard" => {
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(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(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(Value::Null)
}
other => Err(format!("not a synthetic-input gesture: `{other}`")),
}
}
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<u8> = 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<u8>` 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"}),
}
}
}
+4 -3
View File
@@ -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::<View>(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,9 +248,10 @@ 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;
}
+3 -3
View File
@@ -239,7 +239,7 @@ impl<'a> DatePicker<'a> {
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()));
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));
@@ -267,7 +267,7 @@ 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")));
prev.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Previous page")));
register(ui.ctx(), 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),
@@ -280,7 +280,7 @@ 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")));
next.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Next page")));
register(ui.ctx(), format!("datePickerNext:{id}"), next.rect);
if next.clicked() {
view = step(view, true);
+5 -5
View File
@@ -370,7 +370,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::<Panel>(ctx, egui::Id::new("lightcraft-docking"));
craft_ui::docking::cancel_drag::<Panel>(ctx, egui::Id::unique("lightcraft-docking"));
}
}
@@ -401,7 +401,7 @@ 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));
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);
}
@@ -427,7 +427,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")).show_with_limits(
ui,
&workspace.layout,
&style,
@@ -476,7 +476,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::<Panel>(ui.ctx(), egui::Id::new("lightcraft-docking"));
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::unique("lightcraft-docking"));
}
publish_visibility(app, &workspace);
app.ui.docking = Some(workspace);
@@ -488,7 +488,7 @@ 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));
}
}
}
+1 -2
View File
@@ -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;
}
+54 -14
View File
@@ -13,7 +13,6 @@
//! is occluded.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::time::{Duration, Instant};
@@ -156,7 +155,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 {
@@ -314,6 +326,8 @@ 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);
// 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(&mut raw);
let app = &mut self.app;
let mut cursor = egui::CursorIcon::Default;
@@ -326,11 +340,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()) {
@@ -362,6 +375,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()
}
@@ -597,6 +611,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.
@@ -849,7 +864,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);
@@ -1522,7 +1537,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();
@@ -1550,7 +1565,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!(
@@ -2830,6 +2845,25 @@ 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]
@@ -2839,8 +2873,7 @@ mod tests {
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();
@@ -4247,7 +4280,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);
@@ -4284,7 +4317,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::<u8>(egui::Id::new("about_tab")));
let shown = h.view.ctx.data_mut(|d| d.get_temp::<u8>(egui::Id::unique("about_tab")));
assert_eq!(shown.map(usize::from), Some(i), "{tab}");
}
h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t);
@@ -4713,6 +4746,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,
@@ -4791,7 +4825,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();
}
+14 -10
View File
@@ -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));
}
}
@@ -1040,7 +1040,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::<f32>(egui::Id::new(key));
let y = |key: &str| m.get_temp::<f32>(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);
@@ -1058,7 +1058,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
@@ -1155,10 +1155,11 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into"));
register(ui.ctx(), "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);
@@ -1190,7 +1191,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
register(ui.ctx(), "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);
@@ -1226,13 +1227,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);
}
@@ -1296,7 +1298,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();
}
@@ -1305,11 +1307,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();
}
@@ -1319,6 +1322,7 @@ 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| {
@@ -1332,7 +1336,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
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);
let p = ui.painter();
@@ -1415,7 +1419,7 @@ pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 {
/// The "Tags" toggle beside a template field (`button:<key>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::<bool>(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"));
+985 -127
View File
File diff suppressed because it is too large Load Diff
+16 -11
View File
@@ -16,7 +16,7 @@ const EDGE: f32 = 6.0;
const SPEED: f32 = 600.0;
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 +76,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, 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<f32> = ctx.data(|d| d.get_temp(key));
let room = level_room(screen, bar_bottom, depth, anchor(&ctx, depth), content, frame);
@@ -89,13 +89,19 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, 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::<f32>(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());
@@ -103,7 +109,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn
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 +124,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, 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 +138,9 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, 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();
}
}
+1 -1
View File
@@ -985,7 +985,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<egui::Id> {
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;
+5 -4
View File
@@ -656,8 +656,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
} else if app.ui.keyword_painter.is_some() {
app.ui.keyword_painter = None;
} else if app.ui.fullscreen {
app.ui.fullscreen = false;
app.ui.slideshow = None;
// Through `slideshow::end`: it also releases the held slide textures. Clearing the
// two fields directly left every rendered slide in the renderer.
crate::panels::slideshow::end(app);
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail {
@@ -681,7 +682,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
"view.slideshow" => {
// 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"));
@@ -689,7 +690,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
r
}
"slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => {
let now = ctx.input(|i| i.time);
let now = app.tick_now();
crate::panels::slideshow::command(app, id, now)
}
"view.slideshowOptions" => {
+1 -2
View File
@@ -118,7 +118,7 @@ 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) {
pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) {
if app.model_setup.denoise.is_none() {
return;
}
@@ -132,7 +132,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;
}
+1 -1
View File
@@ -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`).
+4 -3
View File
@@ -146,7 +146,7 @@ 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);
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
@@ -157,7 +157,7 @@ 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);
ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas });
@@ -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<String, serde_json::Value> = crate::widgets::take_registry(&harness.ctx)
.into_iter()
.map(|(id, r)| (id, json!([r.left(), r.top(), r.width(), r.height()])))
+1 -1
View File
@@ -101,7 +101,7 @@ 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);
let col = if resp.hovered() { t.text } else { t.text_dim };
+3 -3
View File
@@ -200,7 +200,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;
@@ -306,7 +306,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);
}
@@ -572,7 +572,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);
+20 -7
View File
@@ -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
@@ -165,7 +177,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| note(ui, "Waiting to start…", t.text_dim));
@@ -371,11 +383,12 @@ 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;
@@ -574,7 +587,7 @@ fn count(n: u64, one: &str, many: &str) -> String {
}
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)
}
+21 -21
View File
@@ -211,7 +211,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));
}
@@ -330,7 +330,7 @@ pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_
/// 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)));
@@ -524,7 +524,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);
@@ -935,7 +935,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
@@ -1080,7 +1080,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() {
@@ -1093,7 +1093,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);
@@ -1138,7 +1138,7 @@ 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);
let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 });
@@ -1188,7 +1188,7 @@ 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);
if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() {
@@ -1235,7 +1235,7 @@ 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);
p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 }));
@@ -1263,7 +1263,7 @@ fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) {
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));
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
@@ -1334,7 +1334,7 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect
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());
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()
@@ -1416,8 +1416,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 {
@@ -1847,7 +1847,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,
@@ -2359,7 +2359,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"))),
@@ -2709,7 +2709,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(
@@ -2826,10 +2826,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);
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());
@@ -2846,7 +2846,7 @@ 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());
crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default());
register(ui.ctx(), format!("film:{}", id.0), cr);
let state = app.session.selection.state_of(*id);
@@ -2892,7 +2892,7 @@ 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"
@@ -3289,7 +3289,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]
+16 -13
View File
@@ -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,
_ => {
@@ -174,7 +174,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",
@@ -205,7 +205,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
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;
@@ -570,7 +570,7 @@ 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);
@@ -725,10 +725,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)); });
@@ -756,6 +757,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
});
crate::widgets::name_combo(&c.response, crate::i18n::tr("Preset"));
crate::widgets::register(ui.ctx(), "combo:exportPreset", c.response.rect);
if let Some(name) = chosen
&& let Ok(params) = app.session.export_params(&json!({"preset": name}))
@@ -913,11 +915,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::<bool>(egui::Id::new("wm-graphic"))).unwrap_or(false);
let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::<bool>(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();
}
@@ -989,7 +991,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);
@@ -1268,7 +1270,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::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
@@ -1526,11 +1528,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
@@ -2039,6 +2041,7 @@ 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::name_combo(&c.response, crate::i18n::tr("Resize"));
crate::widgets::register(ui.ctx(), "combo:exportResizeMode", c.response.rect);
if r.mode != before {
// a sensible value for the new unit
@@ -2261,7 +2264,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<u64> = ui.data_mut(|d| d.get_temp(id));
ui.data_mut(|d| d.insert_temp(id, pass));
+12 -11
View File
@@ -595,7 +595,7 @@ fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option<lightcraft_rast
/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so
/// the histogram still follows the edit live; an SDR edit shows what it gets.
fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option<Histogram>) -> Option<Histogram> {
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()));
@@ -656,7 +656,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.",
));
}
@@ -730,9 +730,9 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("highlights", plot.right() - inset, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)),
] {
let rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0));
let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click());
let response = ui.interact(rect, ui.scope_id().with(("histogram-clipping", name)), Sense::click());
register(ui.ctx(), format!("histogram:{name}"), rect);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label)));
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.
@@ -829,7 +829,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}));
@@ -842,6 +842,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}))
@@ -981,7 +982,7 @@ 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);
crate::widgets::context_menu(&resp, |ui| curve_reset_menu(app, ui, &ch));
@@ -1058,7 +1059,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<usize>` so a stale value can be cleared)
let mut dragging: Option<usize> = ui.data(|dd| dd.get_temp::<Option<usize>>(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));
@@ -1526,7 +1527,7 @@ 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.",
@@ -1550,7 +1551,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 grab = match resp.interact_pointer_pos() {
Some(q) if resp.dragged() || resp.clicked() => {
@@ -1616,7 +1617,7 @@ 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);
paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon });
@@ -1797,7 +1798,7 @@ 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)),
)
+18 -7
View File
@@ -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
@@ -642,7 +642,7 @@ 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));
@@ -700,9 +700,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())
@@ -713,6 +719,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;
}
+19 -16
View File
@@ -312,7 +312,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
crate::widgets::register(ui.ctx(), "grid:title", title_rect);
crate::widgets::register(ui.ctx(), "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<Rect> = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active, app.session.source) {
let scroll_to: Option<Rect> = 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
@@ -394,7 +394,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// 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);
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,7 +656,7 @@ 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);
@@ -758,7 +758,7 @@ 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());
let resp = ui.interact(r, egui::Id::unique(("cell", id.0)), Sense::click_and_drag());
register(ui.ctx(), format!("thumb:{}", id.0), r);
let state = app.session.selection.state_of(id);
let selected = state != lightcraft_engine::SelectionState::NotSelected;
@@ -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 {
@@ -902,7 +902,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
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())
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 {
@@ -1065,7 +1065,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) {
@@ -1081,14 +1081,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.ctx(), "dragPhotos", label.rect);
});
},
);
}
pub use super::filterbar::label_color;
@@ -1176,7 +1179,7 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid:
let g = p.layout_no_wrap(text, t.semibold(11.0), Color32::WHITE);
let origin = if square { r.min + vec2(10.0, 26.0) } else { r.min + vec2(5.0, 5.0) };
let br = Rect::from_min_size(origin, vec2(g.size().x + 30.0, 20.0));
let resp = ui.interact(br, egui::Id::new(("stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed {
let resp = ui.interact(br, egui::Id::unique(("stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed {
"Stack — click to expand (S)"
} else {
"Stack — click to collapse (S)"
+1 -1
View File
@@ -19,7 +19,7 @@ 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| {
+12 -9
View File
@@ -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);
@@ -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
@@ -185,7 +188,7 @@ 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);
// while a filter opens the levels the triangle does nothing: it doesn't light up either
@@ -206,7 +209,7 @@ 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);
let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) };
@@ -243,7 +246,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId
register(ui.ctx(), 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);
if resp.hovered() || ar.hovered() {
+8 -8
View File
@@ -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();
@@ -273,7 +273,7 @@ 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 {
@@ -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,7 +469,7 @@ 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);
let r = line(ui, egui::Id::unique(("keyword-offer", k.to_lowercase())), name, place, &whole, lit);
register(ui.ctx(), format!("kwComplete:{k}"), r.rect);
if lit {
register(ui.ctx(), format!("kwCompleteAt:{k}"), r.rect);
@@ -497,7 +497,7 @@ 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);
+1 -1
View File
@@ -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::<Cache>(id)) {
Some(c) if c.file == file => c,
+54 -51
View File
@@ -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::<f32>(egui::Id::new("left-content-width"))).unwrap_or(0.0)
ctx.data(|d| d.get_temp::<f32>(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::<f32>(egui::Id::new("left-content-width-next"), || 0.0);
let m = d.get_temp_mut_or_insert_with::<f32>(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::<f32>(egui::Id::new("left-indent-step"))).unwrap_or(INDENT_STEP)
ctx.data(|d| d.get_temp::<f32>(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::<f32>(egui::Id::new("left-deepest-indent-next"), || 0.0);
let m = d.get_temp_mut_or_insert_with::<f32>(egui::Id::unique("left-deepest-indent-next"), || 0.0);
*m = m.max(indent);
});
indent_x(ui.ctx(), indent)
@@ -145,10 +145,10 @@ fn row_sensed(
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 {
@@ -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::<String>(egui::Id::new(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase())
ui.data(|d| d.get_temp::<String>(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
@@ -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();
}
}
@@ -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,7 +452,7 @@ 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());
let resp = ui.interact(r, ui.scope_id().with((interact, id)), Sense::click_and_drag());
register(ui.ctx(), format!("{widget}:{id}"), r);
let toggled = resp.clicked();
if toggled {
@@ -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();
@@ -672,16 +672,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
drag_auto_scroll(app, ui);
// where the visible part of the content starts (screen y), for the pinned header
let view_top = ui.cursor().top() + viewport.min.y;
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-view-top"), view_top));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-view-top"), view_top));
// rows are as wide as the widest one needs (last frame), at least the panel
let wide = viewport.width().max(content_width(ui.ctx()));
ui.set_min_width(wide);
ui.set_max_width(wide);
ui.data_mut(|d| {
d.insert_temp(egui::Id::new("left-content-width-next"), 0.0f32);
d.insert_temp(egui::Id::new("left-deepest-indent-next"), 0.0f32);
d.insert_temp(egui::Id::unique("left-content-width-next"), 0.0f32);
d.insert_temp(egui::Id::unique("left-deepest-indent-next"), 0.0f32);
// where the visible part of the content ends (screen x), for what stays at the edge
d.insert_temp(egui::Id::new("left-visible-right"), ui.cursor().left() + viewport.max.x);
d.insert_temp(egui::Id::unique("left-visible-right"), ui.cursor().left() + viewport.max.x);
});
let src = app.session.source;
for (id, icon, label, count, s) in [
@@ -719,18 +719,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
above.push('|');
}
above.push_str(name);
set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true);
set_tree_open(ui, egui::Id::unique(("kw-open", above.to_lowercase())), true);
}
// …and the sidebar scrolls to it
ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase()));
ui.data_mut(|d| d.insert_temp(egui::Id::unique(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase()));
}
// the folder Show in Folders went to: Folders is shown and opened, and so are the
// folders above it, also when it was already the one shown and one was folded by hand…
if let Some(path) = app.ui.reveal_folder.take() {
app.ui.sidebar.reveal(SidebarSection::Folders);
ui.data_mut(|d| d.remove::<Option<String>>(egui::Id::new(LIBFOLDER_REVEALED)));
ui.data_mut(|d| d.remove::<Option<String>>(egui::Id::unique(LIBFOLDER_REVEALED)));
// …and the sidebar scrolls to it
ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase()));
ui.data_mut(|d| d.insert_temp(egui::Id::unique(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase()));
}
// Local keeps track of the folder being browsed whether or not it is drawn
let local = local_now(app, ui);
@@ -764,22 +764,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
};
placed.push(at);
}
ui.data_mut(|d| d.remove::<String>(egui::Id::new(SCROLL_TO_ROW)));
ui.data_mut(|d| d.remove::<String>(egui::Id::unique(SCROLL_TO_ROW)));
// room below the last section (a section dragged to the end is dropped here)
ui.add_space(SECTION_GAP * 2.0);
pinned_header(app, ui, &placed, view_top);
section_drag(app, ui, &placed);
// what the rows asked for becomes next frame's width
let next = ui.data(|d| d.get_temp::<f32>(egui::Id::new("left-content-width-next"))).unwrap_or(0.0);
let next = ui.data(|d| d.get_temp::<f32>(egui::Id::unique("left-content-width-next"))).unwrap_or(0.0);
if (next - content_width(ui.ctx())).abs() > 0.5 {
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width"), next));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-content-width"), next));
ui.ctx().request_repaint();
}
// …and the deepest row drawn (or passed over out of sight) sets next frame's step
let deepest = ui.data(|d| d.get_temp::<f32>(egui::Id::new("left-deepest-indent-next"))).unwrap_or(0.0);
let deepest = ui.data(|d| d.get_temp::<f32>(egui::Id::unique("left-deepest-indent-next"))).unwrap_or(0.0);
let step = step_for(viewport.width(), deepest);
if step != indent_step(ui.ctx()) {
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-indent-step"), step));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-indent-step"), step));
ui.ctx().request_repaint();
}
});
@@ -831,7 +831,7 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize {
at: f64,
rev: u64,
}
let id = egui::Id::new("missing-count");
let id = egui::Id::unique("missing-count");
let now = ui.input(|i| i.time);
let rev = app.session.catalog.revision;
let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() });
@@ -1058,7 +1058,7 @@ pub(crate) fn fs_cached_running(ctx: &egui::Context) -> usize {
}
fn fs_running_counter(ctx: &egui::Context) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
ctx.data_mut(|d| d.get_temp_mut_or_default::<std::sync::Arc<std::sync::atomic::AtomicUsize>>(egui::Id::new("fs-cached-running")).clone())
ctx.data_mut(|d| d.get_temp_mut_or_default::<std::sync::Arc<std::sync::atomic::AtomicUsize>>(egui::Id::unique("fs-cached-running")).clone())
}
/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker
@@ -1071,7 +1071,7 @@ pub(crate) fn fs_cached<T: Clone + Send + 'static>(ui: &egui::Ui, kind: &'static
running: bool,
}
type Cell<T> = std::sync::Arc<std::sync::Mutex<Entry<T>>>;
let id = egui::Id::new(("fs-cached", kind, path.to_string()));
let id = egui::Id::unique(("fs-cached", kind, path.to_string()));
let now = ui.input(|i| i.time);
let cell: Cell<T> = match ui.data(|d| d.get_temp::<Cell<T>>(id)) {
Some(c) => c,
@@ -1136,13 +1136,13 @@ fn folder_tree(
reveal: bool,
transient: bool,
) {
let open_id = egui::Id::new(("folder-open", place.to_string(), path.to_string()));
let open_id = egui::Id::unique(("folder-open", place.to_string(), path.to_string()));
let sel = current.is_some_and(|c| same_folder(c, path));
let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path));
let mut open = tree_open(ui, open_id, false);
if reveal && on_the_way {
// opened once per browsed folder: collapsing it again afterwards sticks
let revealed_id = egui::Id::new(("folder-revealed", place.to_string(), path.to_string()));
let revealed_id = egui::Id::unique(("folder-revealed", place.to_string(), path.to_string()));
let target = current.map(str::to_string);
if ui.data(|d| d.get_temp::<Option<String>>(revealed_id)) != Some(target.clone()) {
open = true;
@@ -1220,7 +1220,7 @@ fn folder_tree(
/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open.
fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool {
let open_id = egui::Id::new(("date-open", key.to_string()));
let open_id = egui::Id::unique(("date-open", key.to_string()));
let mut open = tree_open(ui, open_id, false);
let sel = app.session.filter.date.as_deref() == Some(key);
let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent);
@@ -1256,7 +1256,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
let Some(kids) = all.get(&parent) else { return };
for a in kids {
if a.folder {
let open_id = egui::Id::new(("folder-open", a.id.0));
let open_id = egui::Id::unique(("folder-open", a.id.0));
let mut open = tree_open(ui, open_id, true);
if open_to.contains(&a.id) && !open {
open = true;
@@ -1348,7 +1348,7 @@ fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool {
/// Where the pinned header was drawn last frame, if one was (rows are laid out before it).
fn pinned_rect(ui: &egui::Ui) -> Option<Rect> {
ui.data(|d| d.get_temp::<Option<Rect>>(egui::Id::new("left-pinned-rect"))).flatten()
ui.data(|d| d.get_temp::<Option<Rect>>(egui::Id::unique("left-pinned-rect"))).flatten()
}
fn pointer_on_pinned_header(ui: &egui::Ui) -> bool {
@@ -1380,7 +1380,7 @@ fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) {
let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return };
// a section picked up by its pinned header starts inside the top edge: it scrolls once it has
// left the header. Any other drag scrolls there as at any edge, to reach what is above
let from_pinned = egui::Id::new("left-section-drag-from-pinned");
let from_pinned = egui::Id::unique("left-section-drag-from-pinned");
if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::<bool>(from_pinned)) == Some(true) {
if pointer_on_pinned_header(ui) {
return;
@@ -1429,7 +1429,7 @@ fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) {
let ids: Vec<u64> = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect();
ctx.data_mut(|d| {
for id in ids {
d.remove_temp::<f64>(egui::Id::new(("album-hover", id)));
d.remove_temp::<f64>(egui::Id::unique(("album-hover", id)));
}
});
}
@@ -1497,7 +1497,7 @@ fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f3
/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on
/// it; returns whether it did.
fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool {
let hover_id = egui::Id::new(("album-hover", row.id.0));
let hover_id = egui::Id::unique(("album-hover", row.id.0));
let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false };
let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect));
let Some(pos) = pos else {
@@ -1586,11 +1586,14 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return };
let t = Tokens::get(ctx);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
egui::Area::new(egui::Id::new("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| {
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0)));
});
});
egui::Area::new(egui::Id::unique("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(
ctx,
|ui| {
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0)));
});
},
);
}
/// An album row while photos are dragged from the grid: highlighted under the pointer; a
@@ -1812,7 +1815,7 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig
fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder);
let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty());
let seen = egui::Id::new(LIBFOLDER_REVEALED);
let seen = egui::Id::unique(LIBFOLDER_REVEALED);
let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key);
if ui.data(|d| d.get_temp::<Option<String>>(seen)) == Some(now.clone()) {
return;
@@ -1823,7 +1826,7 @@ fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
for n in nodes {
if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) {
let key = lightcraft_catalog::query::folder_key(&n.path);
ui.data_mut(|d| d.insert_temp(egui::Id::new(("libfolder-open", key)), true));
ui.data_mut(|d| d.insert_temp(egui::Id::unique(("libfolder-open", key)), true));
open_above(ui, &n.children, chosen);
}
}
@@ -1844,7 +1847,7 @@ pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a
fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) {
for n in nodes {
let key = lightcraft_catalog::query::folder_key(&n.path);
let open_id = egui::Id::new(("libfolder-open", key.clone()));
let open_id = egui::Id::unique(("libfolder-open", key.clone()));
// a disk starts open, and so does a folder that holds nothing itself and leads to one
// folder (`Users` → `me`): the first row where the library branches is what you look for
let leads_on = n.own == 0 && n.children.len() == 1;
@@ -1970,7 +1973,7 @@ fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool
if *open || n.children.is_empty() {
return;
}
let key = egui::Id::new("folder-spring-open");
let key = egui::Id::unique("folder-spring-open");
let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr());
// held without a break: over this row last frame too
let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) {
@@ -2200,7 +2203,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) {
for n in nodes {
// leaves (most keywords) have no open state to look up
let open_id = (!n.children.is_empty()).then(|| egui::Id::new(("kw-open", n.path.to_lowercase())));
let open_id = (!n.children.is_empty()).then(|| egui::Id::unique(("kw-open", n.path.to_lowercase())));
let mut open = open_id.is_some_and(|k| tree_open(ui, k, false));
let id = format!("keyword:{}", n.path);
if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) {
+2 -2
View File
@@ -90,7 +90,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| {
// swallows clicks: nothing behind the window can be used meanwhile
ui.allocate_rect(screen, egui::Sense::click_and_drag());
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160));
@@ -98,7 +98,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let mut choice = None;
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
egui::Window::new(crate::i18n::tr("Your library couldn't be opened"))
.id(egui::Id::new("library-problem"))
.id(egui::Id::unique("library-problem"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
+1 -1
View File
@@ -123,7 +123,7 @@ struct MenuState {
}
fn state_id() -> egui::Id {
egui::Id::new("maskEffectMenuState")
egui::Id::unique("maskEffectMenuState")
}
/// The effects the menu lists for `query`: (section heading, effect name, its row widget id).
+1 -1
View File
@@ -264,7 +264,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.rect_contains_pointer(r) || !m.visible {
let er = Rect::from_center_size(pos2(r.right() - 16.0, r.center().y), vec2(22.0, 22.0));
register(ui.ctx(), format!("maskVisible:{}", m.id), er);
let eye = ui.interact(er, egui::Id::new(("maskVisible", m.id)), Sense::click());
let eye = ui.interact(er, egui::Id::unique(("maskVisible", m.id)), Sense::click());
crate::widgets::announce_selected(&eye, crate::i18n::tr("Show mask"), m.visible);
paint(
ui.painter(),
+7 -4
View File
@@ -59,11 +59,14 @@ pub fn resizable_side(
reserve: f32,
add: impl FnOnce(&mut egui::Ui),
) -> Option<f32> {
let pid = egui::Id::new(id);
// `Panel::new` stores `IdSalt::new(id)`; the panel's persisted key is
// `parent_ui.scope_id().with_salt(that_salt)`, which is exactly `scope_id().with(id)`.
// (Passing an already-built `Id` through `Panel::left` would salt it a second time.)
let pid = ui.scope_id().with(id);
ui.ctx().data_mut(|d| d.remove::<egui::containers::panel::PanelState>(pid));
let avail = ui.available_rect_before_wrap();
let max = (avail.width() - reserve - crate::state::MIN_PHOTO_WIDTH).clamp(limits.min, limits.max);
let panel = if left { egui::Panel::left(pid) } else { egui::Panel::right(pid) };
let panel = if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
let r = panel.frame(frame).resizable(true).size_range(limits.min..=max).default_size(limits.clamp(width).min(max)).show(ui, add);
crate::widgets::register(ui.ctx(), format!("panel:{id}"), r.response.rect);
// egui names the edge's drag widget `<panel id>.with("__resize")`; the width is measured from
@@ -78,7 +81,7 @@ pub fn resizable_side(
/// The HUD toast at the bottom centre of the canvas.
pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let now = app.tick_now();
let Some((text, until, label)) = app.ui.toast.clone() else { return };
if now > until {
app.ui.toast = None;
@@ -87,7 +90,7 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(canvas) = app.canvas_rect else { return };
let t = Tokens::get(ctx);
let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast")));
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("toast")));
// long messages (a save warning, the web build's notices) wrap within the canvas
let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0);
let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text));
+2 -2
View File
@@ -92,14 +92,14 @@ enum Choice {
fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&str, &str, Choice)]) -> Option<usize> {
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
egui::Area::new(egui::Id::new((id, "dim"))).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
egui::Area::new(egui::Id::unique((id, "dim"))).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
ui.allocate_rect(screen, egui::Sense::click_and_drag());
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
});
let mut chosen = None;
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
egui::Window::new(crate::i18n::tr(title))
.id(egui::Id::new(id))
.id(egui::Id::unique(id))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
+1 -1
View File
@@ -156,7 +156,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, edge: f32, ppp: f32, selected: bool) {
let t = Tokens::get(ui.ctx());
let face = Rect::from_min_size(r.min, vec2(edge, edge));
let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("person-card", &person.name)), Sense::click());
crate::widgets::announce(&resp, person.name.clone());
register(ui.ctx(), format!("person:{}", person.name), r);
let view = app.session.face_view(person.photo, person.face);
+5 -4
View File
@@ -129,7 +129,8 @@ pub(crate) fn handle_bulk_key(app: &mut LightcraftApp, ctx: &egui::Context) -> b
.or_else(|| {
page.more.iter().filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))).find(|f| {
ctx.memory(|m| {
m.has_focus(egui::Id::new(("more-face", f.photo, f.index))) || m.has_focus(egui::Id::new(("more-face-above", f.photo, f.index)))
m.has_focus(egui::Id::unique(("more-face", f.photo, f.index)))
|| m.has_focus(egui::Id::unique(("more-face-above", f.photo, f.index)))
})
})
});
@@ -291,14 +292,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) {
fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit {
let t = Tokens::get(ui.ctx());
let salt = if more { "more-face" } else { "person-face" };
let resp = ui.interact(r, egui::Id::new((salt, f.photo, f.index)), Sense::click());
let resp = ui.interact(r, egui::Id::unique((salt, f.photo, f.index)), Sense::click());
crate::widgets::announce(&resp, name.to_string());
register(ui.ctx(), format!("{salt}:{}:{}", f.photo, f.index), r);
// the × is added after the tile, so it wins where they overlap
let x_rect = Rect::from_min_size(pos2(r.right() - 24.0, r.top() + 4.0), vec2(20.0, 20.0));
let x_resp = more.then(|| ui.interact(x_rect, egui::Id::new(("more-face-x", f.photo, f.index)), Sense::click()));
let x_resp = more.then(|| ui.interact(x_rect, egui::Id::unique(("more-face-x", f.photo, f.index)), Sense::click()));
let above_rect = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(24.0, 20.0));
let above_resp = more.then(|| ui.interact(above_rect, egui::Id::new(("more-face-above", f.photo, f.index)), Sense::click()));
let above_resp = more.then(|| ui.interact(above_rect, egui::Id::unique(("more-face-above", f.photo, f.index)), Sense::click()));
if more {
register(ui.ctx(), format!("more-face-above:{}:{}", f.photo, f.index), above_rect);
}
+9 -9
View File
@@ -62,7 +62,7 @@ impl AppliedByPhoto {
/// Remember `preset` on the photos `preset.apply` just changed (the selection, else the active
/// photo; an active photo outside the selection wasn't changed and isn't marked).
fn remember(ctx: &egui::Context, session: &lightcraft_engine::Session, preset: &str) {
let id = egui::Id::new(Self::ID);
let id = egui::Id::unique(Self::ID);
let mut map: Self = ctx.data(|d| d.get_temp(id)).unwrap_or_default();
for photo in session.selected() {
if let Some(d) = session.develop_of(photo) {
@@ -75,7 +75,7 @@ impl AppliedByPhoto {
/// The preset to mark for `photo`: the one it was given, while its settings are unchanged since.
fn of(ctx: &egui::Context, session: &lightcraft_engine::Session, photo: Option<lightcraft_catalog::PhotoId>) -> Option<String> {
let photo = photo?;
let map: Self = ctx.data(|d| d.get_temp(egui::Id::new(Self::ID)))?;
let map: Self = ctx.data(|d| d.get_temp(egui::Id::unique(Self::ID)))?;
let (preset, then) = map.0.get(&photo)?;
let now = session.develop_of(photo)?;
(Arc::ptr_eq(then, &now) || **then == *now).then(|| preset.clone())
@@ -121,7 +121,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
divider(ui);
// amount slider (applies to the last applied preset)
let amt_id = egui::Id::new("preset-amount");
let amt_id = egui::Id::unique("preset-amount");
let last: Option<AppliedPreset> = ui.data(|d| d.get_temp(amt_id));
if let Some(last) = last.clone().filter(|last| last.is_current(&app.session)) {
let spec = ControlSpec {
@@ -167,7 +167,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let visible = ui.clip_rect().expand(row_h);
for grp in groups.iter() {
let (g, builtin) = (grp.name.clone(), grp.builtin);
let open_id = egui::Id::new(("preset-group", &g));
let open_id = egui::Id::unique(("preset-group", &g));
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
crate::widgets::announce(&resp, g.to_string());
@@ -236,13 +236,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let label = crate::i18n::tr_format!("Preset: {name}", name = name);
app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s });
if let Some(id) = active {
ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label)));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("last-preset-hover"), (id, pr.clone(), label)));
}
}
if resp.clicked() && app.run("preset.apply", json!({"id": pid, "amount": 100})).is_ok() {
AppliedByPhoto::remember(ui.ctx(), &app.session, &pid);
ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, pid.clone(), 100.0)));
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover")));
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::unique("last-preset-hover")));
app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
@@ -296,7 +296,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
ui.add_space(30.0);
});
let last_hover_id = egui::Id::new("last-preset-hover");
let last_hover_id = egui::Id::unique("last-preset-hover");
let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos));
if pointer_in_scroll {
if app.hover_preview.is_none()
@@ -327,7 +327,7 @@ struct Group {
/// The presets by group (groups sorted by name, presets in list order), rebuilt only when the
/// list changed (its generation).
fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc<Vec<Group>> {
let id = egui::Id::new("presets-grouped");
let id = egui::Id::unique("presets-grouped");
if let Some((generation, groups)) = ctx.data(|d| d.get_temp::<(u64, Arc<Vec<Group>>)>(id))
&& generation == presets.generation()
{
@@ -349,7 +349,7 @@ fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc<Vec<Group>> {
/// wherever the group changes. Only the rows in view are built (10 000+ presets made an open
/// picker take seconds a frame). Returns the id of the preset clicked.
pub fn picker_rows(ui: &mut egui::Ui, salt: &str, presets: &PresetList, selected: Option<&str>) -> Option<String> {
let id = egui::Id::new("preset-picker-rows");
let id = egui::Id::unique("preset-picker-rows");
let rows = match ui.ctx().data(|d| d.get_temp::<(u64, Arc<Vec<PickRow>>)>(id)) {
Some((generation, rows)) if generation == presets.generation() => rows,
_ => {
+6 -6
View File
@@ -72,7 +72,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
for (title, tag, imported, list) in sections {
// an imported folder named like a built-in group still opens and closes on its own
let open_id = egui::Id::new(("profile-group", imported, title));
let open_id = egui::Id::unique(("profile-group", imported, title));
let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
crate::widgets::announce(&resp, title.to_string());
@@ -110,7 +110,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.add_space(6.0);
}
ui.add_space(30.0);
let last_hover_id = egui::Id::new("last-profile-hover");
let last_hover_id = egui::Id::unique("last-profile-hover");
let pointer_in_profiles = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| ui.max_rect().contains(pos));
if pointer_in_profiles {
if app.hover_preview.is_none()
@@ -153,7 +153,7 @@ fn cell_ui(
let (p_id, p_name, p_group) = (p.0.as_str(), p.1.as_str(), p.2.as_str());
let t = Tokens::get(ui.ctx());
let key = if tag.is_empty() { format!("profileCell:{p_id}") } else { format!("profileCell:{tag}:{p_id}") };
let resp = ui.interact(cell, egui::Id::new(&key), Sense::click());
let resp = ui.interact(cell, egui::Id::unique(&key), Sense::click());
crate::widgets::announce(&resp, p_name.to_string());
register(ui.ctx(), key, cell);
let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th));
@@ -187,10 +187,10 @@ fn cell_ui(
if resp.hovered() && !applied && !over_star {
app.hover_preview =
Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(p_id, p_name)), settings: s.clone() });
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string())));
ui.data_mut(|m| m.insert_temp(egui::Id::unique("last-profile-hover"), (id, p_id.to_string())));
} else if resp.hovered() && applied {
// resting on the applied profile shows it (no preview), not the last other profile hovered
ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover")));
ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::unique("last-profile-hover")));
}
let resp = resp.on_hover_text(format!(
"{} ({})",
@@ -202,7 +202,7 @@ fn cell_ui(
let _ = app.run("profile.favorite", json!({"id": p_id}));
} else if !applied {
let _ = app.run("develop.profile", json!({"id": p_id, "amount": 100}));
ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover")));
ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::unique("last-profile-hover")));
}
}
}
+21 -19
View File
@@ -45,13 +45,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
area = area.vertical_scroll_offset(offset);
}
// (where the pass starts: by its end egui may have pulled a shrunken panel's offset back)
let area_id = ui.data(|d| d.get_temp::<egui::Id>(egui::Id::new(AREA)));
let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.offset.y));
let area_id = ui.data(|d| d.get_temp::<egui::Id>(egui::Id::unique(AREA)));
let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.clamped_offset().y));
ui.data_mut(|d| match start {
Some(o) => {
d.insert_temp(egui::Id::new(OFFSET), o);
d.insert_temp(egui::Id::unique(OFFSET), o);
}
None => d.remove::<f32>(egui::Id::new(OFFSET)),
None => d.remove::<f32>(egui::Id::unique(OFFSET)),
});
let scrolled = area.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
@@ -69,7 +69,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
RightPanel::None => {}
}
});
ui.data_mut(|d| d.insert_temp(egui::Id::new(AREA), scrolled.id));
ui.data_mut(|d| d.insert_temp(egui::Id::unique(AREA), scrolled.id));
});
}
@@ -83,11 +83,11 @@ const HOLD: &str = "right-scroll-hold";
/// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list
/// in place (a scroll asked for during a pass only lands a frame later).
fn held_offset(ctx: &egui::Context) -> Option<f32> {
let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::new(HOLD)))?;
let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::unique(HOLD)))?;
if asked.checked_add(1) != Some(ctx.cumulative_pass_nr()) {
return None;
}
let offset = ctx.data(|d| d.get_temp::<f32>(egui::Id::new(OFFSET)))?;
let offset = ctx.data(|d| d.get_temp::<f32>(egui::Id::unique(OFFSET)))?;
Some((offset + grew).max(0.0)).filter(|o| o.is_finite())
}
@@ -109,7 +109,7 @@ pub fn fold_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title:
pub fn fold_title(app: &mut LightcraftApp, ui: &egui::Ui, id: &str, r: Rect, resp: &egui::Response, title: &str) -> bool {
let t = Tokens::get(ui.ctx());
let open = !app.ui.section_folded(id);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
crate::widgets::announce_disclosure(resp, true, open, title);
register(ui.ctx(), format!("section:{id}"), r);
let p = ui.painter();
if resp.hovered() {
@@ -178,7 +178,7 @@ fn aspect_label(aspect: Option<(u32, u32)>, original: Option<f64>) -> String {
/// "Custom" row of the aspect menu: two number fields and an Apply button (`crop.aspect` `[w, h]`).
fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let key = egui::Id::new("crop-custom-aspect");
let key = egui::Id::unique("crop-custom-aspect");
let (mut w, mut h): (String, String) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_else(|| ("3".into(), "2".into()));
let mut apply = false;
ui.horizontal(|ui| {
@@ -293,7 +293,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// apply only a new angle
// Esc keeps the old angle, but egui applies the typed text on the frame after the
// one where Esc took the focus away: remember the Esc for that frame
let cancel = egui::Id::new("cropAngleFieldEscaped");
let cancel = egui::Id::unique("cropAngleFieldEscaped");
let escaped = ui.data_mut(|m| m.remove_temp::<bool>(cancel)).unwrap_or(false);
if escape && r.lost_focus() {
ui.data_mut(|m| m.insert_temp(cancel, true));
@@ -744,7 +744,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
// GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it
let gps = m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default();
let gid = egui::Id::new("info-gps");
let gid = egui::Id::unique("info-gps");
ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));
let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY));
@@ -909,6 +909,7 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
}
}
});
crate::widgets::name_combo(&r.response, crate::i18n::tr("Copyright Status"));
register(ui.ctx(), "field:copyrightStatus", r.response.rect);
ui.add_space(6.0);
}
@@ -916,7 +917,7 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
let id = egui::Id::new(("info-field", key));
let id = egui::Id::unique(("info-field", key));
let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string());
let widget = format!("field:{key}");
let field = if lines > 1 {
@@ -956,7 +957,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
let kid = egui::Id::new("kw-input");
let kid = egui::Id::unique("kw-input");
let mut text = ui.data_mut(|d| d.get_temp::<String>(kid).unwrap_or_default());
// the keywords every selected photo has: those only some have are still offered and suggested
let selection = app.session.selected();
@@ -965,7 +966,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// while the box has the cursor it offers the library's keywords for what is being typed,
// right under itself; it stays for a click on one of them, which takes the cursor away
let field = crate::text_field::id("field:keyword");
let oid = egui::Id::new("kw-offers");
let oid = egui::Id::unique("kw-offers");
let pass = ui.ctx().cumulative_pass_nr();
// (remembered from the last frame: the text the highlight was for and the keyword it was
// on, where the offers were drawn, and whether the box was in sight)
@@ -1012,7 +1013,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back)
// (asked for on the frame before this one only: a box that wasn't drawn then, its panel
// switched away, doesn't claim the cursor whenever it is next drawn)
let bid = egui::Id::new("kw-begin");
let bid = egui::Id::unique("kw-begin");
let begin = ui.data_mut(|d| d.remove_temp::<u64>(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass));
let r = crate::text_field::TextField::singleline("field:keyword", &mut text)
.hint(crate::i18n::tr("Add keyword"))
@@ -1081,7 +1082,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.add_space(8.0);
// the painter: click photos in the grid to give them (or take away) a keyword
ui.horizontal(|ui| {
let pid = egui::Id::new("kw-painter");
let pid = egui::Id::unique("kw-painter");
let painting = app.ui.keyword_painter.clone();
let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default());
// (fixed while painting: Stop first)
@@ -1139,7 +1140,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
/// while the change does not come from the box itself (the pointer on it, or typing in its field).
fn hold_list_in_place(ui: &mut egui::Ui, top: f32) {
let height = ui.cursor().top() - top;
let key = egui::Id::new("kw-box-height");
let key = egui::Id::unique("kw-box-height");
let pass = ui.ctx().cumulative_pass_nr();
// (the height drawn on the pass before: one from an earlier visit to the panel says nothing)
let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h);
@@ -1156,7 +1157,7 @@ fn hold_list_in_place(ui: &mut egui::Ui, top: f32) {
if on_box || typing {
return;
}
ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), (grew, pass)));
ui.data_mut(|d| d.insert_temp(egui::Id::unique(HOLD), (grew, pass)));
// (drawn again at once at the new offset, so the list never shows a frame out of place)
ui.ctx().request_discard("the Keyword List holds its place");
}
@@ -1216,6 +1217,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let _ = app.run("keyword.deleteSet", json!({"name": current}));
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Keyword Set"));
register(ui.ctx(), "keywordSetCombo", combo.response.rect);
});
let kws: Vec<String> = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect();
@@ -1271,7 +1273,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
ui.add_space(8.0);
// Named (made by you) and Auto (made by LightCraft) versions
let tab_id = egui::Id::new("versions-tab");
let tab_id = egui::Id::unique("versions-tab");
let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false);
let named_n = p.versions.iter().filter(|v| !v.auto).count();
let items = [("Named", "named"), ("Auto", "auto")];
+28 -18
View File
@@ -51,11 +51,12 @@ fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) {
Match::Any => "any",
Match::None => "none",
};
crate::widgets::combo_box(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| {
let combo = crate::widgets::combo_box(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| {
for x in [Match::All, Match::Any, Match::None] {
ui.selectable_value(m, x, crate::i18n::tr(label(x)));
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Match"));
}
fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &str) {
@@ -149,7 +150,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
let mut unit = stored_unit.to_lowercase();
let mut changed =
ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).clamp_existing_to_range(false).speed(0.2).fixed_decimals(0)).changed();
crate::widgets::combo_box(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| {
let combo = crate::widgets::combo_box(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| {
for u in ["hours", "days", "weeks", "months", "years"] {
if ui.selectable_label(unit.trim_end_matches('s') == u.trim_end_matches('s'), crate::i18n::tr(u)).clicked() {
unit = u.to_string();
@@ -157,6 +158,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
}
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Unit"));
if changed {
*v = json!({"n": n.round(), "unit": unit});
}
@@ -198,26 +200,31 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
(Some(Kind::Number), _) => number_value(ui, v, field),
(Some(Kind::Choice(c)), _) => {
let cur = v.as_str().unwrap_or("").to_string();
crate::widgets::combo_box(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui(ui, |ui| {
for (id, _) in c.iter() {
if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() {
*v = json!(id);
let combo = crate::widgets::combo_box(format!("{salt}-choice"))
.width(120.0)
.selected_text(crate::i18n::choice_text(field, &cur))
.show_ui(ui, |ui| {
for (id, _) in c.iter() {
if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() {
*v = json!(id);
}
}
}
});
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr(field_label(field).unwrap_or(field)));
}
(Some(Kind::Bool), _) => {
// the catalog's reading of the value ("false" is no); one that is neither yes nor no is
// shown as written and kept until a choice replaces it, so the dialog reports it
let cur = bool_value(v);
let text = cur.map_or_else(|| v.to_string(), |b| crate::i18n::bool_text(b).to_string());
crate::widgets::combo_box(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| {
let combo = crate::widgets::combo_box(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| {
for b in [true, false] {
if ui.selectable_label(cur == Some(b), crate::i18n::bool_text(b)).clicked() {
*v = json!(b);
}
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr(field_label(field).unwrap_or(field)));
}
(Some(Kind::Date), _) => {
text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt);
@@ -262,6 +269,7 @@ fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String
}
pick
});
crate::widgets::name_combo(&r.response, crate::i18n::tr("Field"));
register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect);
let Some(Some(id)) = r.inner else { return };
if field == id {
@@ -322,17 +330,19 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path:
field_menu(ui, &rsalt, field, op, value);
let kind = field_kind(field).unwrap_or(Kind::Text);
let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string();
crate::widgets::combo_box(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| {
for (id, l) in ops_for(kind) {
if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id {
let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty");
*op = id.to_string();
if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") {
*value = default_value(field, op);
let combo =
crate::widgets::combo_box(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| {
for (id, l) in ops_for(kind) {
if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id {
let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty");
*op = id.to_string();
if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") {
*value = default_value(field, op);
}
}
}
}
});
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Operator"));
value_editor(ui, field, op, value, &rsalt, env);
// − and + at the end of the row, after the value however long it is: a
// right-to-left layout put them over the value when the row had no room left
+4 -3
View File
@@ -12,10 +12,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
let vid = egui::ViewportId::from_hash_of("lightcraft-second-window");
let builder = egui::ViewportBuilder::default().with_title("LightCraft — Second Window").with_inner_size([960.0, 640.0]);
ctx.show_viewport_immediate(vid, builder, |ctx, class| {
ctx.show_viewport_immediate(vid, builder, |ui, class| {
// 0.37 passes the viewport's root `Ui` (not a `Context`). Do not enter `root_ui` from it.
// (without native windows — web, headless — egui wraps this in a floating window)
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(ctx, |ui| body(app, ui));
if class != egui::ViewportClass::EmbeddedWindow && ctx.input(|i| i.viewport().close_requested()) {
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(ui, |ui| body(app, ui));
if class != egui::ViewportClass::EmbeddedWindow && ui.ctx().input(|i| i.viewport().close_requested()) {
app.ui.second_window = false;
}
});
+15 -8
View File
@@ -203,7 +203,7 @@ fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::sta
// -------------------------------------------------------------------------------------- Import
/// A preset picker: `None` = `none_label`. Returns the new choice when it changed.
fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option<Option<String>> {
fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, current: Option<&str>, none_label: &str) -> Option<Option<String>> {
let name = |pid: &str| {
app.session
.presets
@@ -222,6 +222,7 @@ fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Optio
out = Some(Some(pid));
}
});
crate::widgets::name_combo(&r.response, label);
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
out.filter(|v| v.as_deref() != current)
}
@@ -246,7 +247,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, crate::i18n::tr("Raw defaults"));
hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library."));
row(ui, t, crate::i18n::tr("Raw photos"), |ui| {
if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") {
if let Some(v) = preset_combo(app, ui, "settingsRawPreset", crate::i18n::tr("Raw default"), d.raw_preset.as_deref(), "LightCraft Default") {
let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}}));
}
});
@@ -295,6 +296,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
pick = Some(json!({"camera": cam, "preset": pid}));
}
});
crate::widgets::name_combo(&r.response, crate::i18n::tr("Camera"));
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
if let Some(c) = pick {
let _ = app.run("library.preferences", json!({"camera": c}));
@@ -305,7 +307,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)"));
row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| {
if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") {
if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", crate::i18n::tr("Other preset"), d.other_preset.as_deref(), "None") {
let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}}));
}
});
@@ -315,7 +317,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
[("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())]
{
row(ui, t, label, |ui| {
let id = egui::Id::new(("settingsMeta", key));
let id = egui::Id::unique(("settingsMeta", key));
let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0));
register(ui.ctx(), format!("field:settings.{key}"), r.rect);
@@ -346,6 +348,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
}
});
crate::widgets::name_combo(&r.response, crate::i18n::tr("Metadata preset"));
register(ui.ctx(), "combo:settingsMetaPreset", r.response.rect);
if let Some(n) = pick {
let _ = app.run("library.preferences", json!({"import": {"metadataPreset": n}}));
@@ -396,7 +399,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
row(ui, t, crate::i18n::tr("Album"), |ui| {
let id = egui::Id::new("auto-album");
let id = egui::Id::unique("auto-album");
let mut name = ui.data(|m| m.get_temp::<String>(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default());
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0));
if r.lost_focus() {
@@ -492,7 +495,7 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
#[cfg(not(target_arch = "wasm32"))]
fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
// listing a big folder every frame would be slow: refresh every 2 s and after a change
let cache = egui::Id::new("smart-location");
let cache = egui::Id::unique("smart-location");
let now = ui.input(|i| i.time);
let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) {
Some((at, v)) if now - at < 2.0 => v,
@@ -535,7 +538,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
);
}
// what happens to the previews already built when the folder changes
let mode_id = egui::Id::new("smart-existing");
let mode_id = egui::Id::unique("smart-existing");
let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0);
if count > 0 {
row(ui, t, crate::i18n::tr("Existing previews"), |ui| {
@@ -720,6 +723,10 @@ pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Token
}
}
});
crate::widgets::name_combo(
&r.response,
&crate::i18n::tr_format!("{title}: {line}", title = crate::i18n::tr(title), line = crate::i18n::tr(line.label())),
);
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
}
let r = ui.add_enabled(*lines != defaults, egui::Button::new(crate::i18n::tr("Use Defaults")));
@@ -942,7 +949,7 @@ fn theme_card<V: PartialEq + Copy>(
.on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }));
r.widget_info(|| {
egui::WidgetInfo::labeled(
egui::WidgetType::Button,
egui::Role::Button,
true,
format!(
"{}: {}",
+2 -2
View File
@@ -206,7 +206,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let overlay_key = if overlays.any() { overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 };
let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlay_key.rotate_left(29);
let now = ui.input(|i| i.time);
let mem = egui::Id::new("settingsSlideshowPreviewKey");
let mem = egui::Id::unique("settingsSlideshowPreviewKey");
let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY));
let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id);
if last != key {
@@ -256,7 +256,7 @@ fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: R
let compose = std::sync::Arc::new(lightcraft_engine::slide_title::TitleCompose::new(&look.titles, &look.overlays, title, pw, ph));
let key = compose.key();
let now = ui.input(|i| i.time);
let mem = egui::Id::new("settingsSlideshowTitlePreviewKey");
let mem = egui::Id::unique("settingsSlideshowTitlePreviewKey");
let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY));
if last != key {
ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now)));
+2 -2
View File
@@ -71,7 +71,7 @@ fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, lab
/// A one-line text field (`field:{id}`), saved when the edit ends.
fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) {
let mem = egui::Id::new(("slideshow-overlay-field", id));
let mem = egui::Id::unique(("slideshow-overlay-field", id));
let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string());
let widget = format!("field:{id}");
let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui);
@@ -147,7 +147,7 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let p = s.identity_plate.clone();
let plate = |v: Value| json!({ "identityPlate": v });
if toggle(app, ui, "slideshowPlate", p.enabled, "Identity plate", &|v| plate(json!({"enabled": v}))) {
let mem = egui::Id::new("slideshow-plate-graphic");
let mem = egui::Id::unique("slideshow-plate-graphic");
let mut graphic = !p.image.is_empty() || ui.data(|d| d.get_temp::<bool>(mem)).unwrap_or(false);
let before = graphic;
row(ui, t, "Style", |ui| choices(ui, "slideshowPlateStyle", &[(false, "Text"), (true, "Image")], &mut graphic));
+3 -3
View File
@@ -23,7 +23,7 @@ use crate::widgets::register;
const USER_LIST_H: f32 = 180.0;
fn hovered_key() -> egui::Id {
egui::Id::new("settingsSlideshowTemplateHovered")
egui::Id::unique("settingsSlideshowTemplateHovered")
}
/// The template the pointer is over in the browser (its id), which the preview shows.
@@ -43,7 +43,7 @@ enum Pending {
}
fn pending_key() -> egui::Id {
egui::Id::new("settingsSlideshowTemplatePending")
egui::Id::unique("settingsSlideshowTemplatePending")
}
fn pending(ctx: &egui::Context) -> Option<Pending> {
@@ -228,7 +228,7 @@ pub fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
// save the current settings as a new template
ui.add_space(4.0);
ui.horizontal(|ui| {
let mem = egui::Id::new("settingsSlideshowTemplateNewName");
let mem = egui::Id::unique("settingsSlideshowTemplateNewName");
let mut name: String = ui.data(|d| d.get_temp(mem)).unwrap_or_default();
let r = TextField::singleline("field:slideshowTemplateName", &mut name).hint(crate::i18n::tr("New template name")).width(180.0).show(ui);
let save = crate::widgets::text_button(ui, "slideshowTemplateSave", "Save as Template", false).clicked()
+2 -2
View File
@@ -20,7 +20,7 @@ use crate::widgets::register;
/// Where the preview's choice lives (egui memory): `None` = the photo's slide.
fn preview_key() -> egui::Id {
egui::Id::new("settingsSlideshowPreviewShows")
egui::Id::unique("settingsSlideshowPreviewShows")
}
/// What the Settings preview shows: a title screen, or (`None`) the active photo's slide.
@@ -89,7 +89,7 @@ fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, key: &
/// The screen's text field (`field:{id}`), saved when the edit ends.
fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, value: &str) {
let mem = egui::Id::new(("slideshow-title-field", id));
let mem = egui::Id::unique(("slideshow-title-field", id));
let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string());
let widget = format!("field:{id}");
let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr("Title text")).width(ui.available_width().clamp(80.0, 260.0)).show(ui);
+2 -3
View File
@@ -154,7 +154,7 @@ pub fn ready(app: &LightcraftApp, item: Item) -> bool {
}
/// Advance a running slideshow (every frame, before drawing).
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) {
let Some(run) = app.ui.slideshow.as_ref() else {
app.renderer.release_slides(&[]);
return;
@@ -163,7 +163,6 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
end(app);
return;
}
let now = ctx.input(|i| i.time);
let mut play = run.play.clone();
let t = play.tick(now, |id| ready(app, id));
if let Some(run) = app.ui.slideshow.as_mut() {
@@ -293,7 +292,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// (`ui.pointer` positions are relative to it)
app.image_rect = Some(canvas);
let Some(run) = app.ui.slideshow.as_ref() else { return };
let now = ui.input(|i| i.time);
let now = app.tick_now();
let ppp = ui.ctx().pixels_per_point();
let side = super::detail::texture_side(ui.ctx());
let want = wanted(&run.play, now);
+5 -5
View File
@@ -13,13 +13,13 @@ use crate::widgets::{icon_button, register};
fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, bar: Rect, search_rect: Rect) {
use crate::titlebar::{Gesture, WindowState, command_for};
register(ui.ctx(), "region:titlebar", bar);
let handle = egui::Id::new("titlebar-handle");
let handle = egui::Id::unique("titlebar-handle");
let resp = ui.interact(bar, handle, Sense::click_and_drag());
crate::widgets::announce(&resp, crate::i18n::tr("Title bar"));
// a press that began on a button or the search field is theirs: dragging off it must not move the window
// (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one)
let ctx = ui.ctx().clone();
let memo = egui::Id::new("titlebar-press-on-widget");
let memo = egui::Id::unique("titlebar-press-on-widget");
if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) {
let hovered = ctx.interaction_snapshot(|s| s.hovered.clone());
let on_widget =
@@ -133,7 +133,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
// filter icon right of the search field
let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0));
let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click());
let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click());
crate::widgets::announce(&fresp, crate::i18n::tr("Filters"));
register(ui.ctx(), "icon:filter", fr);
let filtering = app.session.filter != Default::default() || app.ui.filter_bar;
@@ -192,7 +192,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
("bell", Icon::Bell, "Activity", "panel.activity"),
] {
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip));
let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip));
crate::widgets::announce(&resp, crate::i18n::tr(tip));
register(ui.ctx(), format!("icon:{id}"), r);
let warn = id == "cloud" && unsaved.is_some();
@@ -223,7 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode);
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode));
let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone());
let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone());
crate::widgets::announce(&resp, tip.clone());
register(ui.ctx(), "icon:appearance", r);
paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon });
+2 -2
View File
@@ -228,12 +228,12 @@ pub fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed, i: usize
let t = Tokens::get(ui.ctx());
let key = (f.photo, f.index);
let selected = app.ui.unnamed_selected.contains(&key);
let resp = ui.interact(r, egui::Id::new(("unnamed-face", f.photo, f.index)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("unnamed-face", f.photo, f.index)), Sense::click());
crate::widgets::announce_selected(&resp, crate::i18n::tr("Unnamed face"), selected);
register(ui.ctx(), format!("unnamed-face:{}:{}", f.photo, f.index), r);
// a suggested name along the bottom: a click accepts it for this face (added after the tile, so it wins there)
let strip = Rect::from_min_max(pos2(r.left(), r.bottom() - 20.0), r.right_bottom());
let accept = f.suggestion.as_ref().map(|_| ui.interact(strip, egui::Id::new(("unnamed-accept", f.photo, f.index)), Sense::click()));
let accept = f.suggestion.as_ref().map(|_| ui.interact(strip, egui::Id::unique(("unnamed-accept", f.photo, f.index)), Sense::click()));
if let Some(a) = &accept {
register(ui.ctx(), format!("unnamed-accept:{}:{}", f.photo, f.index), a.rect);
}
+4 -2
View File
@@ -382,6 +382,7 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi,
}
}
});
crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper"));
register(ui.ctx(), "combo:printPaper", c.response.rect);
});
if l.page.paper.eq_ignore_ascii_case("custom") {
@@ -483,6 +484,7 @@ fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &Pri
pick |= r.clicked();
}
});
crate::widgets::name_combo(&c.response, crate::i18n::tr("Profile"));
register(ui.ctx(), "combo:printProfile", c.response.rect);
});
if pick && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) {
@@ -677,7 +679,7 @@ fn package_cells(
let photo = if st.layout.style == Style::CustomPackage { c.photo.map(PhotoId).or(first) } else { first };
let rect = sheet.rect(&c.rect);
paint_cell(app, painter, t, st, rect, photo, sheet, &c.rect);
let resp = ui.interact(rect, egui::Id::new(("print-cell", c.id)), Sense::click_and_drag());
let resp = ui.interact(rect, egui::Id::unique(("print-cell", c.id)), Sense::click_and_drag());
register(&ctx, format!("print:cell:{}", c.id), rect);
if resp.clicked() || resp.drag_started() {
pu.selected = Some(c.id);
@@ -719,7 +721,7 @@ fn package_cells(
painter.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside);
let handle = Rect::from_center_size(rect.right_bottom(), Vec2::splat(HANDLE));
painter.rect_filled(handle, 2.0, t.accent);
let resp = ui.interact(handle, egui::Id::new(("print-resize", sel.id)), Sense::drag());
let resp = ui.interact(handle, egui::Id::unique(("print-resize", sel.id)), Sense::drag());
register(&ctx, format!("print:resize:{}", sel.id), handle);
if resp.drag_started() {
pu.drag = Some(Drag { cell: sel.id, origin: sel.rect, resize: true, press: press_origin(ui, &resp), total: Vec2::ZERO });
+11 -2
View File
@@ -662,6 +662,15 @@ impl Renderer {
self.pending.len()
}
/// Test seam: make the renderer look busy without starting a job (capture deadlines).
#[cfg(test)]
pub(crate) fn force_in_flight_for_test(&mut self, count: usize) {
self.pending.clear();
for i in 0..count {
self.pending.insert(Slot::Prefetch((i % 256) as u8), (0, 0));
}
}
/// Why the last render for `slot` failed (until a render with another key succeeds).
pub fn failure(&self, slot: Slot) -> Option<&str> {
self.failed.get(&slot).map(|f| f.1.as_str())
@@ -781,9 +790,9 @@ impl Renderer {
// wasm: run one job per frame on this thread, timed with the host clock
#[cfg(target_arch = "wasm32")]
let inline_ms = {
let t0 = crate::now_ms();
let t0 = crate::monotonic_ms();
self.pool.run_inline(1);
crate::now_ms() - t0
crate::monotonic_ms() - t0
};
#[cfg(not(target_arch = "wasm32"))]
let inline_ms = 0.0;
+5 -6
View File
@@ -332,11 +332,10 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool {
[Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Space].iter().any(|key| input.key_pressed(*key))
});
}
let chrome = app
.ui
.docking
.as_ref()
.is_some_and(|workspace| craft_ui::docking::DockArea::new(egui::Id::new("lightcraft-docking")).chrome_has_focus(ctx, &workspace.layout));
let chrome =
app.ui.docking.as_ref().is_some_and(|workspace| {
craft_ui::docking::DockArea::new(egui::Id::unique("lightcraft-docking")).chrome_has_focus(ctx, &workspace.layout)
});
if !chrome {
return true;
}
@@ -1246,7 +1245,7 @@ mod tests {
},
|ui| {
let ctx = ui.ctx();
egui::Popup::open_id(ctx, egui::Id::new("issue1771-menu"));
egui::Popup::open_id(ctx, egui::Id::unique("issue1771-menu"));
assert!(egui::Popup::is_any_open(ctx));
super::super::handle(&mut h.app, ctx);
},
@@ -36,7 +36,7 @@ fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<Key>, text: O
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() },
|ui| {
if let Some(text) = text.as_deref_mut() {
ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("filter-bar-key-search"))).request_focus();
ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("filter-bar-key-search"))).request_focus();
}
super::handle(app, ui.ctx());
},
@@ -37,7 +37,7 @@ fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<(Key, Modifie
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() },
|ui| {
if let Some(text) = text.as_deref_mut() {
ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("flag-cycle-search"))).request_focus();
ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("flag-cycle-search"))).request_focus();
}
super::handle(app, ui.ctx());
},
@@ -44,7 +44,7 @@ fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<Key>, text: O
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() },
|ui| {
if let Some(text) = text.as_deref_mut() {
ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("relative-rating-search"))).request_focus();
ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("relative-rating-search"))).request_focus();
}
super::handle(app, ui.ctx());
},
+5 -3
View File
@@ -826,10 +826,12 @@ pub struct UiState {
pub external_edits: Vec<u64>,
#[serde(skip)]
pub was_focused: bool,
/// When the watched folder was last scanned (egui time).
/// When the watched folder was last scanned (monotonic app time, so a hidden window keeps
/// scanning).
#[serde(skip)]
pub auto_import_at: f64,
/// When the automatic XMP writes that failed were last tried again (egui time), and how many were waiting then.
/// When the automatic XMP writes that failed were last tried again (monotonic app time), and how
/// many were waiting then.
#[serde(skip)]
pub sidecar_retry_at: f64,
#[serde(skip)]
@@ -856,7 +858,7 @@ pub struct UiState {
#[serde(skip)]
pub sam_downloading: bool,
/// When to start the zoomed-in detail pass of an AI mask (app time) and which mask: set by
/// each click or description, so the pass runs once the clicking stops.
/// each click or description, so the pass runs once the clicking stops (monotonic app time).
#[serde(skip)]
pub detail_due: Option<(f64, u32)>,
/// A mask component being renamed inline: (mask id, component index, name).
+2 -2
View File
@@ -78,7 +78,7 @@ fn more_than_three_rows_overflow_and_expand() {
click(&mut h, "activity:more");
assert_eq!(rows(&h), 5);
// expanded, the stack grows to show them all
let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn");
let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::unique("activity-stack"))).expect("the stack is drawn");
for g in &guards {
let r = rect(&h, &format!("activity:row:{}", g.id()));
assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id());
@@ -504,7 +504,7 @@ fn rows_added_later_grow_the_stack() {
for _ in 0..5 {
h.step();
}
let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn");
let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::unique("activity-stack"))).expect("the stack is drawn");
for g in [&a, &b, &c] {
let r = rect(&h, &format!("activity:row:{}", g.id()));
assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id());
+1 -1
View File
@@ -130,7 +130,7 @@ fn long_dialogs_fit_a_short_window() {
fn a_dialog_list_shrinks_to_fit_a_short_window() {
let mut h = demo([1000.0, 320.0]);
open(&mut h, "app.shortcuts");
let shrink = h.view.ctx.data(|d| d.get_temp::<f32>(egui::Id::new("dialog-list-shrink"))).unwrap_or(0.0);
let shrink = h.view.ctx.data(|d| d.get_temp::<f32>(egui::Id::unique("dialog-list-shrink"))).unwrap_or(0.0);
assert!(shrink > 0.0, "the list gave up room");
let dialog = rect(&h, "dialog:window");
assert!(dialog.height() <= 320.0, "{dialog:?}");
@@ -164,7 +164,7 @@ fn dock_tab_keeps_activation_keys_and_global_command_remains_reachable() {
h.app.run("panel.crop", json!({})).unwrap();
h.step();
// The test chooses the renderer's actual registered tab, rather than an arbitrary focus owner.
let id = egui::Id::new("lightcraft-docking").with(("tab", Panel::Tools));
let id = egui::Id::unique("lightcraft-docking").with(("tab", Panel::Tools));
h.view.ctx.memory_mut(|memory| memory.request_focus(id));
h.step();
key(&mut h, egui::Key::Enter);
@@ -186,7 +186,7 @@ fn content_text_edit_retains_command_keys_and_typed_text() {
h.app.synthetic.push(Event::Text("dock search".into()));
h.step();
assert_eq!(h.app.ui.right, RightPanel::Keywords);
assert_eq!(h.view.ctx.data(|data| data.get_temp::<String>(egui::Id::new("keyword-list-filter"))).as_deref(), Some("dock search"));
assert_eq!(h.view.ctx.data(|data| data.get_temp::<String>(egui::Id::unique("keyword-list-filter"))).as_deref(), Some("dock search"));
}
#[test]
@@ -271,7 +271,7 @@ fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keywo
#[test]
fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() {
let mut h = app();
let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::<bool>::new()));
let divider = egui::Id::unique("lightcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center();
let before = h.app.ui.docking.as_ref().unwrap().layout.clone();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
@@ -303,7 +303,7 @@ fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() {
fn held_divider_replacement_route(route: &str) {
let mut h = app();
let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::<bool>::new()));
let divider = egui::Id::unique("lightcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.view.ctx.read_response(divider).unwrap().rect.center();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
+1 -1
View File
@@ -95,7 +95,7 @@ fn release(h: &mut Headless, at: Pos2) {
/// What the dragged label says this frame instead of the count, if a drop target set it.
fn hint(h: &Headless) -> Option<String> {
h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text)
h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("drag-photos-hint"))).map(|(_, text)| text)
}
/// Click a widget the way a person does (press and release on it).
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -90,7 +90,7 @@ fn amount(app: &LightcraftApp, id: PhotoId) -> f64 {
app.session.develop_of(id).unwrap().enhance.denoise
}
fn resume(app: &mut LightcraftApp) {
model_setup::pump(app, &egui::Context::default());
model_setup::pump(app, &egui::Context::default(), app.tick_now());
}
#[test]
@@ -47,6 +47,7 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() {
pick_folder: Some(Box::new(move || Some(picked.clone()))),
png: Some(Box::new(png)),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
write_new: Some(Box::new(lightcraft_engine::export::write_file_new)),
..Default::default()
};
let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0);
+1
View File
@@ -215,6 +215,7 @@ fn notch(h: &mut Headless, dy: f32) {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
modifiers: egui::Modifiers::NONE,
});
h.step();
+2 -2
View File
@@ -65,7 +65,7 @@ pub struct TextField<'a> {
/// The egui id of the field registered as `widget`, to give it the focus (⌘F to search).
pub fn id(widget: &str) -> egui::Id {
egui::Id::new("lc-text-field").with(widget)
egui::Id::unique("lc-text-field").with(widget)
}
/// Whether the field had an unfinished edit on the immediately preceding egui pass.
@@ -210,7 +210,7 @@ impl<'a> TextField<'a> {
edit = edit.text_color(color);
}
if let Some(align) = self.align {
edit = edit.horizontal_align(align);
edit = edit.align(egui::Align2([align, egui::Align::Min]));
}
if let Some(margin) = self.margin {
edit = edit.margin(margin);
+1 -1
View File
@@ -379,7 +379,7 @@ pub const WHEEL_STRIP_SHARE: f32 = 1.0 / 3.0;
pub const WHEEL_ZOOM_PER_POINT: f32 = 40.0 / 200.0 / WHEEL_LINE_POINTS;
fn theme_id() -> egui::Id {
egui::Id::new("lightcraft-theme")
egui::Id::unique("lightcraft-theme")
}
/// The theme `ctx` is styled with (none before the first [`apply_kind`]).
+38 -19
View File
@@ -9,7 +9,7 @@ use crate::icons::{Icon, paint};
use crate::theme::Tokens;
/// Standard dropdown chevrons, including settings, import/export and rule editors.
pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox {
pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox<'static> {
egui::ComboBox::from_id_salt(id)
.icon(|ui, rect, visuals, _| {
paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, visuals.fg_stroke.color);
@@ -17,6 +17,13 @@ pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox {
.popup_style(list_style.into())
}
/// Name a dropdown for screen readers. egui 0.37's `ComboBox::from_id_salt` publishes only the
/// selected text as the node's value, so an otherwise unlabelled combo has no accessible name and
/// the strict kittest check rejects it. Call this on the response returned by `show_ui`.
pub fn name_combo(response: &egui::Response, label: &str) {
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, response.enabled(), label));
}
/// A dropdown's list and its submenus: items without the resting border of a button, as egui's
/// own menus draw them. An unhovered item gives up its border's width of padding, so with one it
/// grew 2 points when hovered, and the longest name of a list sized to it wrapped (#2354).
@@ -26,7 +33,9 @@ fn list_style(style: &mut egui::Style) {
/// A native button with a vector arrow beside its text; egui reserves the icon's space.
pub fn arrow_button<'a>(ui: &mut Ui, text: impl egui::IntoAtoms<'a>, icon: Icon, leading: bool) -> Response {
let id = ui.next_auto_id().with("arrow");
// 0.37 atoms take a local `IdSalt` (not a root `Id`); the button reports the custom atom's
// rect under the same salt.
let id = egui::IdSalt::new(("lc-arrow", ui.next_auto_id()));
let mut atoms = text.into_atoms();
let arrow = egui::Atom::custom(id, vec2(16.0, 16.0));
if leading {
@@ -76,23 +85,23 @@ pub struct Registry(pub Vec<(String, Rect)>);
/// Record a widget's automation id and screen rect for this frame.
pub fn register(ctx: &egui::Context, id: impl Into<String>, rect: Rect) {
let id = id.into();
ctx.data_mut(|d| d.get_temp_mut_or_default::<Registry>(egui::Id::new("lc-registry")).0.push((id, rect)));
ctx.data_mut(|d| d.get_temp_mut_or_default::<Registry>(egui::Id::unique("lc-registry")).0.push((id, rect)));
}
/// Esc was handled this frame by something nested (an open popup closed on it): containers such
/// as dialogs shouldn't close too. Keyed to the frame, so it lapses by itself on the next one.
pub fn take_escape(ctx: &egui::Context) {
let frame = ctx.cumulative_frame_nr();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("lc-escape-taken"), frame));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("lc-escape-taken"), frame));
}
/// Whether [`take_escape`] was called this frame.
pub fn escape_taken(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<u64>(egui::Id::new("lc-escape-taken"))) == Some(ctx.cumulative_frame_nr())
ctx.data(|d| d.get_temp::<u64>(egui::Id::unique("lc-escape-taken"))) == Some(ctx.cumulative_frame_nr())
}
pub fn take_registry(ctx: &egui::Context) -> Vec<(String, Rect)> {
ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::<Registry>(egui::Id::new("lc-registry")).0))
ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::<Registry>(egui::Id::unique("lc-registry")).0))
}
// ------------------------------------------------------------------ preview-only raws
@@ -143,7 +152,7 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) -> bool {
})
.response;
let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG")));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG")));
register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect);
reload
}
@@ -262,7 +271,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
let pad_r = 22.0;
let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0));
let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0));
let id = ui.id().with(spec.id);
let id = ui.scope_id().with(spec.id);
let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() });
// Keyboard focus survives leaving the row; vertical wheel edits need a fresh click or drag
// in it (at most one slider is armed: `wheel_slider_key`).
@@ -618,13 +627,23 @@ pub fn nudged(spec: &ControlSpec, value: f64, steps: f64) -> f64 {
(((value + unit * steps) / step).round() * step).clamp(spec.min, spec.max)
}
/// Announce a disclosure row with AccessKit's real `Expanded` state.
///
/// egui 0.37's `WidgetInfo::selected` maps to AccessKit `Toggled`; a custom disclosure row must
/// publish `Expanded` instead (the built-in `CollapsingHeader` does this through
/// `accesskit_node_builder`). Use one helper so section headers, flyouts and fold titles agree.
pub(crate) fn announce_disclosure(resp: &egui::Response, enabled: bool, expanded: bool, label: impl ToString) {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::DisclosureTriangle, enabled, label.to_string()));
resp.ctx.accesskit_node_builder(resp.id, |node| node.set_expanded(expanded));
}
/// Collapsible section header ("› Light"). Returns the response (click toggles).
pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: Option<bool>) -> (Response, Option<bool>) {
let title = crate::i18n::tr(title);
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
announce_disclosure(&resp, true, open, title);
register(ui.ctx(), format!("section:{id}"), r);
let p = ui.painter();
if ui.rect_contains_pointer(r) {
@@ -641,7 +660,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O
&& (ui.rect_contains_pointer(r) || !on)
{
let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0));
let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
let er = ui.interact(eye, ui.scope_id().with(("eye", id)), Sense::click());
crate::widgets::announce_selected(&er, crate::i18n::tr("Show / hide"), on);
register(ui.ctx(), format!("sectionEye:{id}"), eye);
paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled });
@@ -670,8 +689,8 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) ->
let w = ui.available_width();
let (outer, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
let r = Rect::from_min_max(pos2(outer.left() + 16.0, outer.top() + 4.0), pos2(outer.right() - 16.0, outer.bottom() - 4.0));
let resp = ui.interact(r, ui.id().with(("flyout", id)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
let resp = ui.interact(r, ui.scope_id().with(("flyout", id)), Sense::click());
announce_disclosure(&resp, true, open, title);
register(ui.ctx(), format!("flyout:{id}"), r);
let p = ui.painter();
p.rect_filled(r, 4.0, if resp.hovered() { t.hover.gamma_multiply(0.8) } else { t.inset });
@@ -696,7 +715,7 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response
let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text);
let size = vec2((galley.size().x + 18.0).max(37.0), 24.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label));
register(ui.ctx(), format!("button:{id}"), r);
let fill = if active {
t.pressed
@@ -753,7 +772,7 @@ pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active:
let tooltip = crate::i18n::tr(tooltip);
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, active, tooltip));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, active, tooltip));
register(ui.ctx(), format!("icon:{id}"), r);
let p = ui.painter();
let sq = Rect::from_center_size(r.center(), vec2(32.0f32.min(size.x), 32.0f32.min(size.y)));
@@ -824,7 +843,7 @@ pub fn dropdown_within(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, co
let elided = galley.elided;
let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0));
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, text));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, text));
register(ui.ctx(), format!("dropdown:{id}"), r);
let c = if resp.hovered() { t.text } else { color };
ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c);
@@ -1004,13 +1023,13 @@ pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str)
/// A hand-drawn clickable area (an icon, a tile, a row) announced as a button named `name`.
pub(crate) fn announce(resp: &egui::Response, name: impl Into<String>) {
let name = name.into();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), name.clone()));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), name.clone()));
}
/// The same for one that is on or off (a toggle, a selected row or swatch).
pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into<String>, selected: bool) {
let name = name.into();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, resp.enabled(), selected, name.clone()));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, resp.enabled(), selected, name.clone()));
}
/// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn
@@ -1064,12 +1083,12 @@ mod numeric_entry_tests;
mod slider_reset_tests;
fn focused_slider_key() -> egui::Id {
egui::Id::new("lightcraft-focused-slider")
egui::Id::unique("lightcraft-focused-slider")
}
/// The slider a click or drag armed for vertical wheel edits, if any.
fn wheel_slider_key() -> egui::Id {
egui::Id::new("lightcraft-wheel-slider")
egui::Id::unique("lightcraft-wheel-slider")
}
/// Whether the widget with keyboard focus is a develop slider selected by a click, drag or Tab.
@@ -206,6 +206,7 @@ fn leaving_a_slider_disarms_wheel_edits_until_it_is_selected_again() {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
modifiers: Modifiers::NONE,
};
h.frame(vec![Event::PointerMoved(inside)]);
@@ -232,7 +233,13 @@ fn leaving_a_slider_disarms_wheel_edits_until_it_is_selected_again() {
let before_sideways = h.value;
h.frame(vec![
Event::PointerMoved(inside),
Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(120.0, 0.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE },
Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(120.0, 0.0),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
modifiers: Modifiers::NONE,
},
]);
for _ in 0..40 {
h.frame(vec![]);
@@ -270,7 +277,13 @@ fn leaving_the_row_during_a_drag_keeps_dragging_but_release_disarms_the_wheel()
let released = h.value;
h.frame(vec![
Event::PointerMoved(inside),
Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE },
Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -120.0),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
modifiers: Modifiers::NONE,
},
]);
for _ in 0..40 {
h.frame(vec![]);
@@ -294,6 +307,7 @@ fn a_drag_that_leaves_and_ends_back_in_the_row_disarms_the_wheel() {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
modifiers: Modifiers::NONE,
}]);
for _ in 0..40 {
@@ -308,6 +322,7 @@ fn leaving_and_returning_within_one_frame_still_disarms_the_wheel() {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
modifiers: Modifiers::NONE,
};
// a slow frame merges the moves out of the row and back (or out of the window and back)
+2 -2
View File
@@ -85,13 +85,13 @@ filmstrip is visible.
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press |
| `ui.text` | `{text}` | Text input |
| `ui.clipboard` | `{action: cut\|copy\|paste, text?}` | Cut, copy or paste in the focused text field, as the system does for ⌘X / ⌘C / ⌘V; `paste` pastes `text`, or the system clipboard without it. Refused when no text field has the focus (outside one, ⌘C / ⌘V are the commands `develop.copy` / `develop.paste`). In the desktop app cut and copy write the system clipboard. `ui.inspect` → `copied` is the text the UI last put on the clipboard |
| `ui.scroll` | `{dx, dy, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms |
| `ui.scroll` | `{dx, dy, source?, unit?, phase?, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms. `source` is `unknown` (default), `wheel`, `trackpad` or `momentum`; `unit` is `point` (default), `line` or `page`; `phase` is `move` (default), `start`, `end` or `cancel`. Wheel/line events drive the grid/filmstrip one-third wheel step and the loupe's notch zoom; trackpad/momentum phases drive kinetic scrolling and rubber-band boundaries. Invalid values are refused and queue nothing |
| `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` |
| `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state |
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) |
| `ui.resize` | `{width, height}` | Resize the window |
| `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) |
| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |
| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. A `path` that did not exist when the request was admitted is written with create-new semantics and is never replaced if a file (including a newly imported original) appears there while the capture is queued; an existing ordinary file may be refreshed. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |
| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) |
| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@ prepare_fonts() {
if [[ -z "${CRAFT_FONTS_DIR:-}" ]] && [[ ! -e "$font_dir" ]]; then
# Same revision as release.yml. Never update or reset an existing checkout.
local revision=3d9ab3a132ed0eff457e424226a435a901da30d3
local revision=b076417b5880bd4154bd22f00695b1d28c6bea83
echo "==> downloading craft-fonts ($revision) to $font_dir"
if ! git clone --no-checkout https://github.com/storytold/craft-fonts "$font_dir" ||
! git -C "$font_dir" checkout --detach "$revision"; then
-1
View File
@@ -121,7 +121,6 @@ if has appimage; then
esac
APPDIR="$WORK/LightCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
ln -s usr/bin/lightcraft "$APPDIR/AppRun"
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
+1 -1
View File
@@ -6,7 +6,7 @@ function Initialize-CraftFonts([string] $WorkspaceRoot) {
if (-not $env:CRAFT_FONTS_DIR -and -not (Test-Path -LiteralPath $fontDir)) {
# Same revision as release.yml and packaging/fonts.sh. Existing checkouts stay untouched.
$revision = '3d9ab3a132ed0eff457e424226a435a901da30d3'
$revision = 'b076417b5880bd4154bd22f00695b1d28c6bea83'
Write-Output "==> downloading craft-fonts ($revision) to $fontDir"
& git clone --no-checkout https://github.com/storytold/craft-fonts $fontDir
if ($LASTEXITCODE -ne 0) { throw 'Could not download craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR' }